--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit ec74a2651e897325b14269ae52d11b1e12f0b6e2
Parents : 9f71f34
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-19T14:02:21-05:00
feat: improve audio handling in Docker and headless environments with hostless LXST audio backends and web audio bridge integration
Changes
59 files changed, 3055 insertions(+), 516 deletions(-)
Diff
diff --git a/Dockerfile b/Dockerfile
index 25c303d0..b0622c69 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -69,10 +69,12 @@ RUN uv sync --no-group dev --no-install-project && \
COPY meshchatx ./meshchatx
COPY scripts/docker-bake-lxst-filterlib-musl.py ./scripts/docker-bake-lxst-filterlib-musl.py
COPY scripts/patch_lxst_pyogg_ogg_ctypes.py ./scripts/patch_lxst_pyogg_ogg_ctypes.py
+COPY scripts/patch_lxst_codec2_optional.py ./scripts/patch_lxst_codec2_optional.py
COPY --from=build-frontend /src/meshchatx/public ./meshchatx/public
RUN pip install --no-cache-dir . && \
python scripts/patch_lxst_pyogg_ogg_ctypes.py && \
+ python scripts/patch_lxst_codec2_optional.py && \
python scripts/docker-bake-lxst-filterlib-musl.py && \
rm -rf /opt/venv/lib/python*/site-packages/LXST/Platforms/android && \
find /opt/venv -type d -name "tests" -exec rm -rf {} + && \
@@ -109,6 +111,8 @@ LABEL org.opencontainers.image.created="${OCI_CREATED}"
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
+# No PulseAudio in the image: LXST LineSource/LineSink cannot open host devices.
+ENV MESHCHAT_FORCE_WEB_AUDIO=1
USER meshchat
diff --git a/Dockerfile.hardened b/Dockerfile.hardened
index 1d8c706f..0b8855ce 100644
--- a/Dockerfile.hardened
+++ b/Dockerfile.hardened
@@ -60,10 +60,12 @@ RUN uv sync --no-group dev --no-install-project && \
COPY meshchatx ./meshchatx
COPY scripts/patch_lxst_pyogg_ogg_ctypes.py ./scripts/patch_lxst_pyogg_ogg_ctypes.py
+COPY scripts/patch_lxst_codec2_optional.py ./scripts/patch_lxst_codec2_optional.py
COPY --from=build-frontend /src/meshchatx/public ./meshchatx/public
RUN pip install --no-cache-dir . && \
python scripts/patch_lxst_pyogg_ogg_ctypes.py && \
+ python scripts/patch_lxst_codec2_optional.py && \
rm -rf /opt/venv/lib/python*/site-packages/LXST/Platforms/android && \
find /opt/venv -type d -name "tests" -exec rm -rf {} + && \
find /opt/venv -type d -name "test" -exec rm -rf {} + && \
@@ -99,6 +101,7 @@ LABEL org.opencontainers.image.created="${OCI_CREATED}"
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
+ENV MESHCHAT_FORCE_WEB_AUDIO=1
USER meshchat
diff --git a/Taskfile.yml b/Taskfile.yml
index 399f3589..4fa27ead 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -95,6 +95,7 @@ tasks:
cmds:
- 'uv sync --group dev{{if ne .UV_SYNC_FLAGS ""}} {{.UV_SYNC_FLAGS}}{{end}}'
- uv run python scripts/patch_lxst_pyogg_ogg_ctypes.py
+ - uv run python scripts/patch_lxst_codec2_optional.py
deps:backend:rns:
aliases: [deps:be:rns, pip-rns]
@@ -162,6 +163,7 @@ tasks:
cmds:
- 'uv sync --group dev{{if ne .UV_SYNC_FLAGS ""}} {{.UV_SYNC_FLAGS}}{{end}}'
- uv run python scripts/patch_lxst_pyogg_ogg_ctypes.py
+ - uv run python scripts/patch_lxst_codec2_optional.py
- uv run pip install ruff
# --- Execution ---
diff --git a/docs/agents/conventions/tests.md b/docs/agents/conventions/tests.md
index c2c00f8e..61e583a5 100644
--- a/docs/agents/conventions/tests.md
+++ b/docs/agents/conventions/tests.md
@@ -9,6 +9,24 @@ Applies when editing `tests/**/*.{py,js}`.
- Landlock tests that apply the sandbox must run in a subprocess (one restrict per process).
- Long-running / notification soak suites can hang. Prefer timeouts and avoid piping pytest through `tail` in agent shells.
+## Oracle style (no soft fuzz)
+
+Property and fuzz tests must assert an accept or reject outcome, not only that nothing crashed.
+
+Refuse these patterns:
+
+- Bare `except Exception: pass` around the code under test
+- `never_raises` tests with no postcondition
+- Asserting only that a result dict has an `"ok"` key without checking True or False
+- Mocks that return the success path for every input under a security oracle
+
+Prefer:
+
+- Independent oracle: given input X, predict accept or reject, then assert the code matches
+- Jail oracles: on success, resolved path stays under the allowed root
+- Closed reason sets: on `ValueError`, the message is one of the known machine reasons
+- Round-trip or shape invariants when the API is pure parsing
+
## Extended Edge Case Tester (EECT) and Live Validation (LV)
- EECT packs live under `tests/backend/eect/packs/` and use marker `eect`.
diff --git a/docs/en/audio-calls.md b/docs/en/audio-calls.md
index 1c935145..1f0892a9 100644
--- a/docs/en/audio-calls.md
+++ b/docs/en/audio-calls.md
@@ -81,6 +81,8 @@ Callee UI: ring, answer, or decline
- Use headphones on mobile and Quest builds to prevent echo.
- Review microphone permissions in Electron or the Android system settings if the UI shows no input level.
- Keep LXST and Reticulum versions aligned with MeshChatX release notes when upgrading.
+- **Docker / headless web**: containers have no PulseAudio host devices. MeshChatX forces the web audio bridge (`MESHCHAT_FORCE_WEB_AUDIO=1`) and installs hostless LXST backends so calls can use the browser mic/speaker. Enable telephone in settings, then place a call from the web UI over HTTPS.
+- **Android Codec2**: native `libcodec2.so` must be preloaded before `pycodec2`. If Codec2 profiles are hidden, check `/api/v1/telephone/codec2/status` and rebuild with vendor wheels that bundle `pycodec2/libcodec2.so`.
## See also
diff --git a/docs/en/tools.md b/docs/en/tools.md
index 8026202c..3f2a62b0 100644
--- a/docs/en/tools.md
+++ b/docs/en/tools.md
@@ -17,11 +17,11 @@ Use these when messages or pages fail despite interfaces showing as enabled.
## File transfer and shell
-| Tool | Purpose |
-| ------------ | ---------------------------------------------------- |
-| RNCP | Send or fetch files over Reticulum |
+| Tool | Purpose |
+| ------------ | -------------------------------------------------------- |
+| RNCP | Send or fetch files over Reticulum |
| RNS FileSync | Sync a directory with peers over `rns_filesync.filesync` |
-| RNSH | Remote shell sessions with streamed output |
+| RNSH | Remote shell sessions with streamed output |
RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`.
FileSync progress and peer events use `filesync.sync.progress`, `filesync.peer.connected`, `filesync.peer.disconnected`, `filesync.file.updated`, `filesync.file.deleted`, and `filesync.error`.
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 5fb7207d..53531b3e 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/android_codec2.py b/meshchatx/android_codec2.py
index 25dcc15b..0d4baca2 100644
--- a/meshchatx/android_codec2.py
+++ b/meshchatx/android_codec2.py
@@ -6,6 +6,7 @@ from __future__ import annotations
import ctypes
import logging
+import os
import sys
from pathlib import Path
@@ -51,12 +52,24 @@ def _libcodec2_candidates() -> list[Path]:
seen.add(key)
candidates.append(path)
+ explicit = Path(os.environ.get("MESHCHAT_LIBCODEC2_PATH", "") or "")
+ if explicit.is_file():
+ add(explicit)
+
for entry in sys.path:
if not entry:
continue
root = Path(entry)
add(root / "pycodec2" / "libcodec2.so")
add(root / "chaquopy" / "lib" / "libcodec2.so")
+ # Some Chaquopy layouts nest native libs under site-packages directly.
+ add(root / "libcodec2.so")
+
+ # Extracted APK native lib dirs (ABI-specific jniLibs sync target).
+ for env_key in ("MESHCHAT_NATIVE_LIB_DIR", "ANDROID_NATIVE_LIBRARY_DIR"):
+ native_dir = Path(os.environ.get(env_key, "") or "")
+ if native_dir.is_dir():
+ add(native_dir / "libcodec2.so")
return candidates
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 2712b816..0b324591 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -453,11 +453,13 @@ class ReticulumMeshChat:
memory_diag_enabled: bool = False,
plugins_enabled: bool = True,
defer_network_setup: bool = False,
+ headless: bool = False,
):
self.running = True
self.plugins_enabled = plugins_enabled
self._memory_diag_enabled = memory_diag_enabled
self._mem_diag = None
+ self._headless = bool(headless)
self.migration_context = (
migration_context if migration_context is not None else {}
)
@@ -506,8 +508,10 @@ class ReticulumMeshChat:
self._startup_error: str | None = None
self._network_ready = not defer_network_setup
self._network_degraded = False
- self._ui_ready = not defer_network_setup
+ # HTTP can serve the shell immediately while RNS/identity finish.
+ self._ui_ready = True
self._rns_recovery_actions: list[str] = []
+ self._reticulum_secondary_started = False
# track announce timestamps for rate calculation
self.announce_timestamps = []
@@ -541,7 +545,11 @@ class ReticulumMeshChat:
self._auto_resend_coordinator = AutoResendCoordinator()
AsyncUtils.ensure_background_loop()
- self.web_audio_bridge = WebAudioBridge(None, None)
+ self.web_audio_bridge = WebAudioBridge(
+ None,
+ None,
+ force_enabled=self.web_audio_required(),
+ )
self.rns_link_manager = RnsLinkManager(
self_identity_getter=lambda: self.identity,
reticulum_getter=lambda: getattr(self, "reticulum", None),
@@ -568,6 +576,41 @@ class ReticulumMeshChat:
else:
self.setup_identity(identity)
self._mark_network_ready()
+ self._finish_deferred_startup_services()
+
+ def web_audio_required(self) -> bool:
+ """True when LXST host audio is unusable and the browser bridge is mandatory.
+
+ Chaquopy Android has no usable LXST LineSource path. Docker/Alpine images
+ typically lack PulseAudio. Do not key this off --headless alone: frozen
+ Electron also uses headless (no auto browser) while still having host audio.
+
+ MESHCHAT_FORCE_WEB_AUDIO=1 forces the bridge for debugging or custom hosts.
+ """
+ if _is_chaquopy_android():
+ return True
+ force = os.environ.get("MESHCHAT_FORCE_WEB_AUDIO", "").strip().lower()
+ if force in ("1", "true", "yes", "on"):
+ return True
+ cached = getattr(self, "_host_audio_unavailable_cached", None)
+ if cached is not None:
+ return cached
+ unavailable = self._probe_host_audio_unavailable()
+ self._host_audio_unavailable_cached = unavailable
+ return unavailable
+
+ @staticmethod
+ def _probe_host_audio_unavailable() -> bool:
+ """Return True when LXST cannot open a host capture device."""
+ try:
+ from LXST.Sources import Backend
+
+ if Backend is None:
+ return True
+ Backend()
+ return False
+ except Exception:
+ return True
# Proxy properties for backward compatibility
@property
@@ -1600,6 +1643,7 @@ class ReticulumMeshChat:
except Exception as exc:
print(f"Failed to persist session secret into config: {exc}")
self._mark_network_ready()
+ self._finish_deferred_startup_services()
print("Network stack ready", flush=True)
if self.websocket_clients:
try:
@@ -1649,7 +1693,10 @@ class ReticulumMeshChat:
self.web_audio_bridge = WebAudioBridge(
self.current_context.telephone_manager,
self.current_context.config,
+ force_enabled=self.web_audio_required(),
)
+ if self._network_ready:
+ self._finish_deferred_startup_services()
return
# Initialize Reticulum if not already done
@@ -1663,18 +1710,7 @@ class ReticulumMeshChat:
)
_restore_rns_console_logging_after_reticulum_init(self)
self.page_node_manager.load_nodes()
- self.page_node_manager.start_all()
self.plugin_manager.set_app(self)
- if self.plugins_enabled:
- try:
- self.plugin_manager.install_bundled_examples()
- except Exception as exc:
- print(f"Bundled plugin sync failed: {exc}", flush=True)
- try:
- self.sideband_plugin_loader.reload()
- self._ensure_sideband_telemetry_loop()
- except Exception as exc:
- print(f"Sideband plugin loader init failed: {exc}")
# Create new context
self._set_startup_stage("identity")
@@ -1685,6 +1721,7 @@ class ReticulumMeshChat:
self.web_audio_bridge = WebAudioBridge(
context.telephone_manager,
context.config,
+ force_enabled=self.web_audio_required(),
)
for node in self.page_node_manager.nodes.values():
@@ -1707,6 +1744,43 @@ class ReticulumMeshChat:
)
self._health_monitor.start()
+ if self._network_ready:
+ self._finish_deferred_startup_services()
+
+ def _finish_deferred_startup_services(self) -> None:
+ """Start non-critical services after network_ready is published."""
+ context = self.current_context
+ if context is not None:
+ try:
+ context.setup_deferred_services()
+ except Exception as exc:
+ print(f"Deferred identity services failed: {exc}", flush=True)
+ self._start_deferred_reticulum_services()
+
+ def _start_deferred_reticulum_services(self) -> None:
+ if self._reticulum_secondary_started:
+ return
+ if not hasattr(self, "reticulum"):
+ return
+ self._reticulum_secondary_started = True
+ try:
+ self.page_node_manager.start_all()
+ for node in self.page_node_manager.nodes.values():
+ if node.running and node.destination:
+ self._register_local_page_node_announce(node)
+ except Exception as exc:
+ print(f"Deferred page node start failed: {exc}", flush=True)
+ if self.plugins_enabled:
+ try:
+ self.plugin_manager.install_bundled_examples()
+ except Exception as exc:
+ print(f"Bundled plugin sync failed: {exc}", flush=True)
+ try:
+ self.sideband_plugin_loader.reload()
+ self._ensure_sideband_telemetry_loop()
+ except Exception as exc:
+ print(f"Sideband plugin loader init failed: {exc}")
+
def _checkpoint_and_close(self):
# delegated to database instance
self.database._checkpoint_and_close()
@@ -2563,6 +2637,7 @@ class ReticulumMeshChat:
if switched_instance_name:
self._write_reticulum_instance_name(instance_restore_name)
self._mark_network_ready()
+ self._finish_deferred_startup_services()
await self._send_rns_reload_status(
"done",
"RNS reload complete.",
@@ -2587,6 +2662,7 @@ class ReticulumMeshChat:
try:
self.setup_identity(identity_to_restore)
self._mark_network_ready()
+ self._finish_deferred_startup_services()
return False
except Exception as recover_exc:
self._mark_network_degraded(
@@ -5531,6 +5607,7 @@ class ReticulumMeshChat:
self._network_degraded = False
self._ui_ready = True
self._network_ready = False
+ self._reticulum_secondary_started = False
if hasattr(self, "reticulum"):
with contextlib.suppress(Exception):
delattr(self, "reticulum")
@@ -5538,6 +5615,7 @@ class ReticulumMeshChat:
try:
self.setup_identity(identity)
self._mark_network_ready()
+ self._finish_deferred_startup_services()
return web.json_response(
{
"message": "Network stack recovered",
@@ -7784,9 +7862,10 @@ class ReticulumMeshChat:
)
await websocket_response.prepare(request)
- # Chaquopy Android has no LXST host audio device, so always allow the websocket bridge.
+ # Chaquopy Android and headless/web deployments have no usable LXST
+ # host audio device, so always allow the websocket bridge.
web_audio_allowed = (
- self.web_audio_bridge.config_enabled() or _is_chaquopy_android()
+ self.web_audio_bridge.config_enabled() or self.web_audio_required()
)
if not web_audio_allowed:
await websocket_response.send_str(
@@ -10680,16 +10759,18 @@ class ReticulumMeshChat:
"get",
lambda: False,
)()
- or _is_chaquopy_android()
+ or self.web_audio_required()
)
and not bool(
getattr(self.voicemail_manager, "is_recording", False),
),
+ "required": self.web_audio_required(),
"allow_fallback": getattr(
self.config.telephone_web_audio_allow_fallback,
"get",
lambda: True,
- )(),
+ )()
+ and not self.web_audio_required(),
"has_client": bool(
getattr(self.web_audio_bridge, "clients", []),
),
@@ -23514,9 +23595,7 @@ class ReticulumMeshChat:
continue
fields = parse_fields_dict(failed_message.get("fields"))
- allow_attachments = (
- ctx.config.allow_auto_resending_failed_messages_with_attachments.get()
- )
+ allow_attachments = ctx.config.allow_auto_resending_failed_messages_with_attachments.get()
if not allow_attachments and fields_have_attachments(fields):
print(
"Not resending failed message with attachments, as setting is disabled",
@@ -24252,6 +24331,7 @@ def main():
memory_diag_enabled=args.memory_diag,
plugins_enabled=not args.disable_plugins,
defer_network_setup=not needs_immediate_network,
+ headless=bool(args.headless),
)
# store recovery on app for wiring with identity context
@@ -24332,6 +24412,7 @@ def main():
)
reticulum_meshchat.setup_identity(identity)
reticulum_meshchat._mark_network_ready()
+ reticulum_meshchat._finish_deferred_startup_services()
else:
print(f"Error: Snapshot not found at {snapshot_path}")
diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index 4cc51ecc..e87c9269 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -257,16 +257,22 @@ class Database:
return True
return False
- def check_db_health_at_open(self, storage_path):
+ def check_db_health_at_open(self, storage_path, *, quick: bool = False):
"""Run integrity and baseline checks after opening the database.
Returns human-readable issue strings. Empty if healthy.
+ When quick is True, use PRAGMA quick_check instead of full integrity_check.
"""
issues = []
try:
- integrity_rows = self.provider.integrity_check()
+ integrity_rows = (
+ self.provider.quick_check()
+ if quick
+ else self.provider.integrity_check()
+ )
+ check_label = "quick check" if quick else "integrity check"
if not integrity_rows:
- issues.append("Database integrity check failed: no result")
+ issues.append(f"Database {check_label} failed: no result")
_log.warning("DB open health check: no result")
else:
first = integrity_rows[0]
@@ -274,7 +280,7 @@ class Database:
next(iter(first.values())) if isinstance(first, dict) else first[0]
)
if val != "ok":
- issues.append(f"Database integrity check failed: {val!s}")
+ issues.append(f"Database {check_label} failed: {val!s}")
_log.warning("DB open health check: %s", val)
except Exception as e:
msg = f"Database integrity check error: {e!s}"
diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py
index 52074322..c7f102b6 100644
--- a/meshchatx/src/backend/docs_manager.py
+++ b/meshchatx/src/backend/docs_manager.py
@@ -27,7 +27,15 @@ class DocsManager:
build time with scripts/build/fetch_reticulum_manual.py (pnpm run build-docs).
"""
- def __init__(self, config, public_dir, project_root=None, storage_dir=None):
+ def __init__(
+ self,
+ config,
+ public_dir,
+ project_root=None,
+ storage_dir=None,
+ *,
+ populate: bool = True,
+ ):
self.config = config
self.public_dir = public_dir
self.project_root = project_root
@@ -65,6 +73,11 @@ class DocsManager:
logging.exception(f"Failed to create documentation directories: {e}")
self.last_error = str(e)
+ if populate:
+ self.ensure_meshchatx_docs_populated()
+
+ def ensure_meshchatx_docs_populated(self):
+ """Copy/render MeshChatX docs into storage when writable."""
if os.path.exists(self.meshchatx_docs_dir) and os.access(
self.meshchatx_docs_dir,
os.W_OK,
diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index c560838f..c2b1a435 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -116,6 +116,11 @@ class IdentityContext:
)
self.running = False
+ self._deferred_setup_done = False
+ self._deferred_setup_lock = threading.Lock()
+ self._deferred_setup_in_progress = False
+ self._deferred_setup_finished = threading.Event()
+ self._deferred_setup_finished.set()
def _rrc_name_for_identity_hash(self, identity_hash):
try:
@@ -146,11 +151,20 @@ class IdentityContext:
pass
def setup(self):
+ """Initialize core messaging identity state.
+
+ Secondary tools (RN*, bots, RRC connect, docs populate, map overlays)
+ are started by setup_deferred_services() after network_ready so the UI
+ and LXMF path become available sooner.
+ """
print(f"Setting up Identity Context for {self.identity_hash}...")
# 0. Clear any previous integrity and database health issues on the app
self.app.integrity_issues = []
self.app.database_health_issues = []
+ self._deferred_setup_done = False
+ self._deferred_setup_in_progress = False
+ self._deferred_setup_finished.set()
# 1. Cleanup RNS state for this identity if any lingers
self.app.cleanup_rns_state_for_identity(self.identity.hash)
@@ -162,9 +176,9 @@ class IdentityContext:
else:
self.database = Database(self.database_path)
- # Check Integrity (skip in emergency mode)
+ # Critical integrity only at boot (full walk deferred)
if not getattr(self.app, "emergency", False):
- is_ok, issues = self.integrity_manager.check_integrity()
+ is_ok, issues = self.integrity_manager.check_integrity(critical_only=True)
if not is_ok:
print(
f"INTEGRITY WARNING for {self.identity_hash}: {', '.join(issues)}",
@@ -192,10 +206,9 @@ class IdentityContext:
self.database.initialize()
self.database._tune_sqlite_pragmas()
- # 3. Initialize Config and Managers
+ # 3. Initialize Config and core managers
self.config = ConfigManager(self.database)
- # Apply overrides from CLI/ENV if provided
if (
hasattr(self.app, "gitea_base_url_override")
and self.app.gitea_base_url_override
@@ -206,18 +219,7 @@ class IdentityContext:
self.announce_manager = AnnounceManager(self.database, self.config)
self.archiver_manager = ArchiverManager(self.database)
self.map_manager = MapManager(self.config, self.app.storage_dir)
- self.map_overlay_manager = MapOverlayManager(
- self.config,
- self.database,
- self.storage_path,
- reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None),
- identity=self.identity,
- reticulum=getattr(self.app, "reticulum", None),
- )
- try:
- self.map_overlay_manager.start_scheduler()
- except Exception:
- pass
+ self.map_overlay_manager = None
self.docs_manager = DocsManager(
self.config,
self.app.get_public_path(),
@@ -227,6 +229,7 @@ class IdentityContext:
),
),
storage_dir=self.storage_path,
+ populate=False,
)
self.repository_server_manager = RepositoryServerManager(
self.storage_path,
@@ -241,7 +244,10 @@ class IdentityContext:
self.database.messages.mark_stuck_messages_as_failed()
if not getattr(self.app, "emergency", False):
- db_issues = self.database.check_db_health_at_open(self.storage_path)
+ db_issues = self.database.check_db_health_at_open(
+ self.storage_path,
+ quick=True,
+ )
if db_issues:
self.app.database_health_issues = db_issues
print(
@@ -266,9 +272,7 @@ class IdentityContext:
self.config.lxmf_propagation_sync_limit_in_bytes.get() / 1000
)
- # Register LXMF delivery identity
inbound_stamp_cost = self.config.lxmf_inbound_stamp_cost.get()
- # Enforce max stamp cost when block strangers is enabled on startup
if (
self.config.block_all_from_strangers.get()
and isinstance(inbound_stamp_cost, int)
@@ -282,7 +286,6 @@ class IdentityContext:
stamp_cost=inbound_stamp_cost,
)
- # Forwarding Manager
self.forwarding_manager = ForwardingManager(
self.database,
self.lxmf_router_path,
@@ -291,12 +294,10 @@ class IdentityContext:
)
self.forwarding_manager.load_aliases()
- # Register delivery callback
self.message_router.register_delivery_callback(
lambda msg: self.app.on_lxmf_delivery(msg, context=self),
)
- # Restore preferred propagation node on startup
with contextlib.suppress(Exception):
preferred_node = (
self.config.lxmf_preferred_propagation_node_destination_hash.get()
@@ -304,90 +305,12 @@ class IdentityContext:
if preferred_node:
self.app.set_active_propagation_node(preferred_node, context=self)
- # Enable local propagation node on startup if configured
with contextlib.suppress(Exception):
if self.config.lxmf_local_propagation_node_enabled.get():
self.app.enable_local_propagation_node(True, context=self)
- # 5. Initialize Handlers and Managers
- self.rncp_handler = RNCPHandler(
- reticulum_instance=getattr(self.app, "reticulum", None),
- identity=self.identity,
- storage_dir=self.app.storage_dir,
- )
- self.rncp_handler.on_receive_completed = self._rncp_emit_receive_completed
- self.rns_filesync_handler = RnsFilesyncHandler(
- reticulum_instance=getattr(self.app, "reticulum", None),
- identity=self.identity,
- storage_dir=self.storage_path,
- emit_callback=self._filesync_emit,
- )
- self.rnsh_manager = RNSHManager(
- storage_dir=self.storage_path,
- reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None),
- )
- self.rnsh_manager.set_change_callback(
- lambda session: self.app.on_rnsh_change(session, context=self),
- )
- self.rnsh_manager.set_output_callback(
- lambda session, chunk: self.app.on_rnsh_output(
- session,
- chunk,
- context=self,
- ),
- )
- try:
- self.rnsh_manager.load()
- except Exception as exc:
- print(f"Failed to load RNSH sessions for {self.identity_hash}: {exc}")
- self.rnx_manager = RNXManager(
- storage_dir=self.storage_path,
- reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None),
- )
- self.rnx_manager.set_change_callback(
- lambda session: self.app.on_rnx_change(session, context=self),
- )
- self.rnx_manager.set_output_callback(
- lambda session, chunk: self.app.on_rnx_output(session, chunk, context=self),
- )
- try:
- self.rnx_manager.load()
- except Exception as exc:
- print(f"Failed to load RNX sessions for {self.identity_hash}: {exc}")
- self.rnstatus_handler = RNStatusHandler(
- reticulum_instance=getattr(self.app, "reticulum", None),
- )
- self.rnpath_handler = RNPathHandler(
- reticulum_instance=getattr(self.app, "reticulum", None),
- )
- self.rnpath_trace_handler = RNPathTraceHandler(
- reticulum_instance=getattr(self.app, "reticulum", None),
- identity=self.identity,
- )
- self.rnprobe_handler = RNProbeHandler(
- reticulum_instance=getattr(self.app, "reticulum", None),
- identity=self.identity,
- )
-
- libretranslate_url = self.config.libretranslate_url.get()
- libretranslate_api_key = self.config.libretranslate_api_key.get()
- self.translator_handler = TranslatorHandler(
- libretranslate_url=libretranslate_url,
- libretranslate_api_key=libretranslate_api_key,
- translator_argos_enabled=self.config.translator_argos_enabled.get(),
- translator_libretranslate_enabled=self.config.translator_libretranslate_enabled.get(),
- )
+ # Telephone is part of core UX (calls overlay).
- self.bot_handler = BotHandler(
- identity_path=self.storage_path,
- config_manager=self.config,
- )
- try:
- self.bot_handler.restore_enabled_bots()
- except Exception as exc:
- print(f"Failed to restore bots: {exc}")
-
- # Initialize managers
identity = self.identity
if identity is None:
msg = "identity is required for manager setup"
@@ -398,6 +321,9 @@ class IdentityContext:
storage_dir=self.storage_path,
db=self.database,
)
+ self.telephone_manager.web_audio_required = bool(
+ getattr(self.app, "web_audio_required", lambda: False)(),
+ )
self.telephone_manager.get_name_for_identity_hash = (
self.app.get_name_for_identity_hash
)
@@ -418,7 +344,6 @@ class IdentityContext:
lambda call: self.app.on_telephone_call_ended(call, context=self),
)
- # Only initialize telephone hardware/profile if not in emergency mode
if not getattr(self.app, "emergency", False):
self.telephone_manager.init_telephone()
with contextlib.suppress(Exception):
@@ -462,65 +387,275 @@ class IdentityContext:
context=self,
)
- # Reticulum Relay Chat (optional)
- rrc_enabled = self.config.rrc_enabled.get() if self.config else True
- if rrc_enabled:
- self.rrc_manager = RRCManager(
+ # Tool handlers stay None until deferred setup finishes.
+ self.rncp_handler = None
+ self.rns_filesync_handler = None
+ self.rnsh_manager = None
+ self.rnx_manager = None
+ self.rnstatus_handler = None
+ self.rnpath_handler = None
+ self.rnpath_trace_handler = None
+ self.rnprobe_handler = None
+ self.translator_handler = None
+ self.bot_handler = None
+ self.rrc_manager = None
+ self.rrc_server_manager = None
+
+ self.register_announce_handlers()
+
+ self.running = True
+ self.start_background_threads()
+
+ print(f"Identity Context for {self.identity_hash} core is now running.")
+
+ def setup_deferred_services(self):
+ """Finish non-critical managers after network_ready.
+
+ Idempotent and teardown-safe: concurrent callers share one run, and
+ teardown waits for an in-flight run so handlers are not resurrected
+ after the context is stopped.
+ """
+ if not self.running:
+ return
+ with self._deferred_setup_lock:
+ if self._deferred_setup_done or self._deferred_setup_in_progress:
+ return
+ if not self.running:
+ return
+ self._deferred_setup_in_progress = True
+ self._deferred_setup_finished.clear()
+
+ try:
+ if not self.running:
+ return
+ print(f"Deferred setup for Identity Context {self.identity_hash}...")
+ self._run_deferred_services_body()
+ if self.running:
+ with self._deferred_setup_lock:
+ self._deferred_setup_done = True
+ print(
+ f"Identity Context for {self.identity_hash} deferred setup complete.",
+ )
+ else:
+ print(
+ f"Deferred setup aborted for torn-down identity {self.identity_hash}",
+ )
+ finally:
+ with self._deferred_setup_lock:
+ self._deferred_setup_in_progress = False
+ self._deferred_setup_finished.set()
+
+ def _deferred_still_active(self) -> bool:
+ return bool(self.running)
+
+ def _run_deferred_services_body(self):
+ if not self._deferred_still_active():
+ return
+
+ try:
+ if not self._deferred_still_active():
+ return
+ self.map_overlay_manager = MapOverlayManager(
+ self.config,
+ self.database,
+ self.storage_path,
+ reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None),
+ identity=self.identity,
+ reticulum=getattr(self.app, "reticulum", None),
+ )
+ try:
+ self.map_overlay_manager.start_scheduler()
+ except Exception:
+ pass
+ except Exception as exc:
+ print(f"Failed to start map overlay manager: {exc}")
+
+ try:
+ if self.docs_manager is not None and self._deferred_still_active():
+ self.docs_manager.ensure_meshchatx_docs_populated()
+ except Exception as exc:
+ print(f"Failed to populate docs: {exc}")
+
+ if not self._deferred_still_active():
+ return
+
+ try:
+ self.rncp_handler = RNCPHandler(
+ reticulum_instance=getattr(self.app, "reticulum", None),
+ identity=self.identity,
+ storage_dir=self.app.storage_dir,
+ )
+ self.rncp_handler.on_receive_completed = self._rncp_emit_receive_completed
+ if not self._deferred_still_active():
+ return
+ self.rns_filesync_handler = RnsFilesyncHandler(
+ reticulum_instance=getattr(self.app, "reticulum", None),
identity=self.identity,
storage_dir=self.storage_path,
- get_nickname=lambda: (
- self.config.display_name.get() if self.config else None
- ),
- get_name_for_identity_hash=self._rrc_name_for_identity_hash,
+ emit_callback=self._filesync_emit,
)
- self.rrc_manager.set_change_callback(
- lambda hub: self.app.on_rrc_change(hub, context=self),
+ self.rnsh_manager = RNSHManager(
+ storage_dir=self.storage_path,
+ reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None),
)
- self.rrc_manager.set_message_callback(
- lambda hub, msg: self.app.on_rrc_message(hub, msg, context=self),
+ self.rnsh_manager.set_change_callback(
+ lambda session: self.app.on_rnsh_change(session, context=self),
+ )
+ self.rnsh_manager.set_output_callback(
+ lambda session, chunk: self.app.on_rnsh_output(
+ session,
+ chunk,
+ context=self,
+ ),
)
try:
- self.rrc_manager.load()
+ self.rnsh_manager.load()
except Exception as exc:
- print(f"Failed to load RRC hubs for {self.identity_hash}: {exc}")
-
- self.rrc_server_manager = RRCServerManager(
+ print(f"Failed to load RNSH sessions for {self.identity_hash}: {exc}")
+ if not self._deferred_still_active():
+ return
+ self.rnx_manager = RNXManager(
storage_dir=self.storage_path,
- owner_identity=self.identity.hash,
+ reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None),
)
- self.rrc_server_manager.set_change_callback(
- lambda hub: self.app.on_rrc_server_change(hub, context=self),
+ self.rnx_manager.set_change_callback(
+ lambda session: self.app.on_rnx_change(session, context=self),
+ )
+ self.rnx_manager.set_output_callback(
+ lambda session, chunk: self.app.on_rnx_output(
+ session,
+ chunk,
+ context=self,
+ ),
)
- self.rrc_manager.set_server_manager(self.rrc_server_manager)
try:
- self.rrc_server_manager.load()
+ self.rnx_manager.load()
except Exception as exc:
- print(
- f"Failed to load RRC hub servers for {self.identity_hash}: {exc}",
- )
+ print(f"Failed to load RNX sessions for {self.identity_hash}: {exc}")
+ self.rnstatus_handler = RNStatusHandler(
+ reticulum_instance=getattr(self.app, "reticulum", None),
+ )
+ self.rnpath_handler = RNPathHandler(
+ reticulum_instance=getattr(self.app, "reticulum", None),
+ )
+ self.rnpath_trace_handler = RNPathTraceHandler(
+ reticulum_instance=getattr(self.app, "reticulum", None),
+ identity=self.identity,
+ )
+ self.rnprobe_handler = RNProbeHandler(
+ reticulum_instance=getattr(self.app, "reticulum", None),
+ identity=self.identity,
+ )
+
+ libretranslate_url = self.config.libretranslate_url.get()
+ libretranslate_api_key = self.config.libretranslate_api_key.get()
+ self.translator_handler = TranslatorHandler(
+ libretranslate_url=libretranslate_url,
+ libretranslate_api_key=libretranslate_api_key,
+ translator_argos_enabled=self.config.translator_argos_enabled.get(),
+ translator_libretranslate_enabled=self.config.translator_libretranslate_enabled.get(),
+ )
+ self.bot_handler = BotHandler(
+ identity_path=self.storage_path,
+ config_manager=self.config,
+ )
try:
- self.rrc_manager.connect_auto_reconnect_hubs()
+ self.bot_handler.restore_enabled_bots()
except Exception as exc:
- print(
- f"Failed to auto-connect RRC hubs for {self.identity_hash}: {exc}",
+ print(f"Failed to restore bots: {exc}")
+ except Exception as exc:
+ print(f"Failed deferred tool manager setup: {exc}")
+
+ if not self._deferred_still_active():
+ return
+
+ try:
+ rrc_enabled = self.config.rrc_enabled.get() if self.config else True
+ if rrc_enabled:
+ self.rrc_manager = RRCManager(
+ identity=self.identity,
+ storage_dir=self.storage_path,
+ get_nickname=lambda: (
+ self.config.display_name.get() if self.config else None
+ ),
+ get_name_for_identity_hash=self._rrc_name_for_identity_hash,
)
- else:
- self.rrc_manager = None
- self.rrc_server_manager = None
+ self.rrc_manager.set_change_callback(
+ lambda hub: self.app.on_rrc_change(hub, context=self),
+ )
+ self.rrc_manager.set_message_callback(
+ lambda hub, msg: self.app.on_rrc_message(hub, msg, context=self),
+ )
+ try:
+ self.rrc_manager.load()
+ except Exception as exc:
+ print(f"Failed to load RRC hubs for {self.identity_hash}: {exc}")
+
+ self.rrc_server_manager = RRCServerManager(
+ storage_dir=self.storage_path,
+ owner_identity=self.identity.hash,
+ )
+ self.rrc_server_manager.set_change_callback(
+ lambda hub: self.app.on_rrc_server_change(hub, context=self),
+ )
+ self.rrc_manager.set_server_manager(self.rrc_server_manager)
+ try:
+ self.rrc_server_manager.load()
+ except Exception as exc:
+ print(
+ f"Failed to load RRC hub servers for {self.identity_hash}: {exc}",
+ )
- # 6. Register Announce Handlers
- self.register_announce_handlers()
+ try:
+ self.rrc_manager.connect_auto_reconnect_hubs()
+ except Exception as exc:
+ print(
+ f"Failed to auto-connect RRC hubs for {self.identity_hash}: {exc}",
+ )
+ else:
+ self.rrc_manager = None
+ self.rrc_server_manager = None
+ except Exception as exc:
+ print(f"Failed deferred RRC setup: {exc}")
- # 7. Start background threads
- self.running = True
- self.start_background_threads()
+ if not self._deferred_still_active():
+ return
- # Baseline integrity manifest after successful setup
if not getattr(self.app, "emergency", False):
- self.integrity_manager.save_manifest()
+ try:
+ is_ok, issues = self.integrity_manager.check_integrity()
+ if not is_ok:
+ print(
+ f"INTEGRITY WARNING (deferred) for {self.identity_hash}: {', '.join(issues)}",
+ )
+ if not hasattr(self.app, "integrity_issues"):
+ self.app.integrity_issues = []
+ for issue in issues:
+ if issue not in self.app.integrity_issues:
+ self.app.integrity_issues.append(issue)
+ if self._deferred_still_active():
+ self.integrity_manager.save_manifest()
+ except Exception as exc:
+ print(f"Failed deferred integrity pass: {exc}")
- print(f"Identity Context for {self.identity_hash} is now running.")
+ try:
+ if not self._deferred_still_active():
+ return
+ full_db_issues = self.database.check_db_health_at_open(
+ self.storage_path,
+ quick=False,
+ )
+ if full_db_issues:
+ existing = list(
+ getattr(self.app, "database_health_issues", []) or [],
+ )
+ for issue in full_db_issues:
+ if issue not in existing:
+ existing.append(issue)
+ self.app.database_health_issues = existing
+ except Exception as exc:
+ print(f"Failed deferred DB health check: {exc}")
def start_background_threads(self):
# start background thread for auto announce loop
@@ -673,6 +808,13 @@ class IdentityContext:
def teardown(self):
print(f"Tearing down Identity Context for {self.identity_hash}...")
self.running = False
+ # Let an in-flight deferred setup notice running=False and exit before
+ # we null managers it may still be assigning.
+ finished = getattr(self, "_deferred_setup_finished", None)
+ if finished is not None and not finished.wait(timeout=30):
+ print(
+ f"Timed out waiting for deferred setup during teardown of {self.identity_hash}",
+ )
if self.auto_propagation_manager:
self.auto_propagation_manager.stop()
self.auto_propagation_manager = None
diff --git a/meshchatx/src/backend/integrity_manager.py b/meshchatx/src/backend/integrity_manager.py
index 5d143917..7403aeb9 100644
--- a/meshchatx/src/backend/integrity_manager.py
+++ b/meshchatx/src/backend/integrity_manager.py
@@ -164,8 +164,12 @@ class IntegrityManager:
except Exception as e:
return False, str(e)
- def check_integrity(self):
- """Verify the current state against the last saved manifest using advanced analytics."""
+ def check_integrity(self, critical_only: bool = False):
+ """Verify the current state against the last saved manifest using advanced analytics.
+
+ When critical_only is True, only identity and database markers are checked.
+ The full storage walk runs later so startup is not blocked by hashing every file.
+ """
if not self.manifest_path.exists():
return True, ["Initial run - no manifest yet"]
@@ -205,6 +209,32 @@ class IntegrityManager:
f"Database structural anomaly (Entropy Δ: {abs(actual_entropy - saved_entropy):.2f})",
)
+ # Critical identity/config files only during fast startup path.
+ if critical_only:
+ for rel_path, expected_hash in manifest_files.items():
+ if self._should_ignore(rel_path):
+ continue
+ if not any(marker in rel_path for marker in ("identity", "config")):
+ continue
+ full_path = self.storage_dir / rel_path
+ if not full_path.exists():
+ issues.append(f"File missing: {rel_path}")
+ continue
+ actual_hash = self._hash_file(full_path)
+ if actual_hash != expected_hash:
+ issues.append(
+ f"Critical security component integrity compromised: {rel_path}",
+ )
+ if issues:
+ m_date = manifest.get("date", "Unknown")
+ m_time = manifest.get("time", "Unknown")
+ issues.insert(
+ 0,
+ f"Last integrity snapshot: {m_date} {m_time} (Identity: {m_id})",
+ )
+ self.issues = issues
+ return len(issues) == 0, issues
+
# Check other critical files in storage_dir
for root, _, files_in_dir in os.walk(self.storage_dir):
for file in files_in_dir:
diff --git a/meshchatx/src/backend/rncp_handler.py b/meshchatx/src/backend/rncp_handler.py
index 29d70e62..6212d1f3 100644
--- a/meshchatx/src/backend/rncp_handler.py
+++ b/meshchatx/src/backend/rncp_handler.py
@@ -265,11 +265,17 @@ class RNCPHandler:
if not self.fetch_jail:
return self.REQ_FETCH_NOT_ALLOWED
+ if not isinstance(data, str) or "\x00" in data:
+ return self.REQ_FETCH_NOT_ALLOWED
+
if data.startswith(self.fetch_jail + "/"):
data = data.replace(self.fetch_jail + "/", "")
- file_path = os.path.realpath(
- os.path.join(self.fetch_jail, data.lstrip("/")),
- )
+ try:
+ file_path = os.path.realpath(
+ os.path.join(self.fetch_jail, data.lstrip("/")),
+ )
+ except (OSError, ValueError):
+ return self.REQ_FETCH_NOT_ALLOWED
jail_real = os.path.realpath(self.fetch_jail)
if file_path != jail_real and not file_path.startswith(jail_real + os.sep):
return self.REQ_FETCH_NOT_ALLOWED
diff --git a/meshchatx/src/backend/rns_filesync_handler.py b/meshchatx/src/backend/rns_filesync_handler.py
index 031e9a54..fee597cb 100644
--- a/meshchatx/src/backend/rns_filesync_handler.py
+++ b/meshchatx/src/backend/rns_filesync_handler.py
@@ -12,9 +12,27 @@ from collections.abc import Callable
from typing import Any
from rns_filesync.constants import ANNOUNCE_INTERVAL_DEFAULT
+from rns_filesync.paths import PathJailError, normalize_relpath
from rns_filesync.permissions import PermissionStore
from rns_filesync.service import FileSyncService
+_ALL_ALIASES = frozenset({"all", "a", "everyone", "*"})
+
+
+def _normalize_peer_hash(value: str | None) -> str | None:
+ cleaned = str(value or "").strip().lower().replace(":", "")
+ if not cleaned:
+ return None
+ if cleaned in _ALL_ALIASES:
+ return "all"
+ if len(cleaned) != 32:
+ return None
+ try:
+ bytes.fromhex(cleaned)
+ except ValueError:
+ return None
+ return cleaned
+
class RnsFilesyncHandler:
"""Host FileSync against the shared Reticulum stack for one identity."""
@@ -28,12 +46,13 @@ class RnsFilesyncHandler:
):
self.reticulum = reticulum_instance
self.identity = identity
- self.storage_dir = storage_dir
+ self.storage_dir = os.path.realpath(storage_dir)
self._emit_callback = emit_callback
self._lock = threading.RLock()
self.service: FileSyncService | None = None
+ self._permissions_cache: PermissionStore | None = None
- self._root = os.path.join(storage_dir, "filesync")
+ self._root = os.path.join(self.storage_dir, "filesync")
self._settings_path = os.path.join(self._root, "settings.json")
self._acl_path = os.path.join(self._root, "acl.txt")
self._sync_directory = os.path.join(self._root, "sync")
@@ -52,8 +71,15 @@ class RnsFilesyncHandler:
except Exception:
pass
- def _default_sync_directory(self) -> str:
- return os.path.join(self._root, "sync")
+ def _resolve_sync_directory(self, path: str) -> str | None:
+ cleaned = str(path or "").strip()
+ if not cleaned:
+ return None
+ resolved = os.path.realpath(os.path.expanduser(cleaned))
+ root = self.storage_dir
+ if resolved != root and not resolved.startswith(root + os.sep):
+ return None
+ return resolved
def _load_settings(self) -> None:
os.makedirs(self._root, exist_ok=True)
@@ -68,7 +94,9 @@ class RnsFilesyncHandler:
return
sync_dir = data.get("sync_directory")
if isinstance(sync_dir, str) and sync_dir.strip():
- self._sync_directory = os.path.realpath(os.path.expanduser(sync_dir.strip()))
+ resolved = self._resolve_sync_directory(sync_dir)
+ if resolved is not None:
+ self._sync_directory = resolved
monitor = data.get("monitor")
if isinstance(monitor, bool):
self._monitor = monitor
@@ -91,9 +119,23 @@ class RnsFilesyncHandler:
def _load_permissions(self) -> PermissionStore:
permissions = PermissionStore()
+ enforce_override: bool | None = None
if os.path.isfile(self._acl_path):
- with contextlib.suppress(Exception):
- permissions.load_file(self._acl_path)
+ try:
+ with open(self._acl_path, encoding="utf-8") as handle:
+ text = handle.read()
+ lines = text.splitlines()
+ if lines:
+ first = lines[0].strip()
+ if first == "# enforce=false":
+ enforce_override = False
+ elif first == "# enforce=true":
+ enforce_override = True
+ permissions.load_allowed_text(text)
+ if enforce_override is not None:
+ permissions._enforce = enforce_override
+ except Exception:
+ pass
return permissions
def _save_acl(self, permissions: PermissionStore) -> None:
@@ -104,16 +146,15 @@ class RnsFilesyncHandler:
for target in rules.get(perm, []):
short = {"read": "r", "write": "w", "delete": "d"}[perm]
lines.append(f"{short}:{target}")
- if permissions.enabled and not lines:
- lines.append("# enforce=true")
- elif not permissions.enabled:
- lines.insert(0, "# enforce=false")
- else:
+ if permissions.enabled:
lines.insert(0, "# enforce=true")
+ else:
+ lines.insert(0, "# enforce=false")
tmp = f"{self._acl_path}.tmp"
with open(tmp, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
os.replace(tmp, self._acl_path)
+ self._permissions_cache = permissions
def _wire_callbacks(self, service: FileSyncService) -> None:
service.on_peer_connected = lambda payload: self._emit(
@@ -144,7 +185,10 @@ class RnsFilesyncHandler:
def _permissions(self) -> PermissionStore:
if self.service is not None:
return self.service.permissions
- return self._load_permissions()
+ if self._permissions_cache is not None:
+ return self._permissions_cache
+ self._permissions_cache = self._load_permissions()
+ return self._permissions_cache
def get_status(self) -> dict[str, Any]:
with self._lock:
@@ -180,10 +224,13 @@ class RnsFilesyncHandler:
) -> dict[str, Any]:
with self._lock:
if sync_directory is not None:
- cleaned = str(sync_directory).strip()
- if not cleaned:
- return {"ok": False, "error": "sync_directory is required"}
- self._sync_directory = os.path.realpath(os.path.expanduser(cleaned))
+ resolved = self._resolve_sync_directory(sync_directory)
+ if resolved is None:
+ return {
+ "ok": False,
+ "error": "sync_directory must stay under identity storage",
+ }
+ self._sync_directory = resolved
if monitor is not None:
self._monitor = bool(monitor)
if announce_interval is not None:
@@ -202,14 +249,7 @@ class RnsFilesyncHandler:
os.makedirs(self._sync_directory, exist_ok=True)
permissions = self._load_permissions()
- if os.path.isfile(self._acl_path):
- try:
- with open(self._acl_path, encoding="utf-8") as handle:
- first = handle.readline().strip()
- if first == "# enforce=false":
- permissions._enforce = False
- except Exception:
- pass
+ self._permissions_cache = permissions
service = FileSyncService(
identity=self.identity,
@@ -244,6 +284,7 @@ class RnsFilesyncHandler:
def teardown(self) -> None:
self.stop()
+ self._permissions_cache = None
def list_peers(self) -> list[dict[str, Any]]:
with self._lock:
@@ -261,9 +302,9 @@ class RnsFilesyncHandler:
with self._lock:
if self.service is None:
return {"ok": False, "error": "filesync is not running"}
- cleaned = str(identity_hash or "").strip().lower().replace(":", "")
- if not cleaned:
- return {"ok": False, "error": "identity_hash is required"}
+ cleaned = _normalize_peer_hash(identity_hash)
+ if cleaned is None or cleaned == "all":
+ return {"ok": False, "error": "invalid identity_hash"}
return self.service.connect_peer(cleaned)
def disconnect_peer(self, peer_id: str) -> dict[str, Any]:
@@ -294,6 +335,10 @@ class RnsFilesyncHandler:
timeout_f = float(timeout)
except (TypeError, ValueError):
timeout_f = 10.0
+ if timeout_f < 0.1:
+ timeout_f = 0.1
+ if timeout_f > 120.0:
+ timeout_f = 120.0
files = self.service.browse_peer(cleaned, timeout=timeout_f)
return {"ok": True, "peer_id": cleaned, "files": files}
@@ -307,7 +352,11 @@ class RnsFilesyncHandler:
return {"ok": False, "error": "peer_id is required"}
if not cleaned_path:
return {"ok": False, "error": "path is required"}
- return self.service.download_file(cleaned_peer, cleaned_path)
+ try:
+ safe_path = normalize_relpath(cleaned_path)
+ except PathJailError as exc:
+ return {"ok": False, "error": str(exc)}
+ return self.service.download_file(cleaned_peer, safe_path)
def get_acl(self) -> dict[str, Any]:
with self._lock:
@@ -333,12 +382,12 @@ class RnsFilesyncHandler:
permissions.load_allowed_text(rules_text)
else:
permissions = self._permissions()
- if self.service is None:
- # Work on a fresh copy so we can persist even when stopped.
- permissions = self._load_permissions()
- if identity_hash and perms is not None:
- granted = permissions.grant(identity_hash, perms)
+ if identity_hash is not None and perms is not None:
+ peer = _normalize_peer_hash(identity_hash)
+ if peer is None:
+ return {"ok": False, "error": "invalid identity_hash"}
+ granted = permissions.grant(peer, perms)
if not granted and perms:
return {"ok": False, "error": "no valid permissions provided"}
@@ -373,10 +422,13 @@ class RnsFilesyncHandler:
"ok": False,
"error": "stop filesync before changing sync directory",
}
- cleaned = str(sync_directory).strip()
- if not cleaned:
- return {"ok": False, "error": "sync_directory is required"}
- self._sync_directory = os.path.realpath(os.path.expanduser(cleaned))
+ resolved = self._resolve_sync_directory(sync_directory)
+ if resolved is None:
+ return {
+ "ok": False,
+ "error": "sync_directory must stay under identity storage",
+ }
+ self._sync_directory = resolved
if monitor is not None:
self._monitor = bool(monitor)
diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py
index 6889b4e7..d028a14e 100644
--- a/meshchatx/src/backend/telephone_manager.py
+++ b/meshchatx/src/backend/telephone_manager.py
@@ -92,6 +92,9 @@ class TelephoneManager:
self.preferred_profile_id = None
self._caller_allowed = None
self._blocked_identity_hashes = None
+ # When True, LXST must not open PulseAudio LineSource/LineSink (Docker /
+ # headless / Android web bridge). Set by ReticulumMeshChat.
+ self.web_audio_required = False
@property
def is_recording(self):
@@ -168,6 +171,13 @@ class TelephoneManager:
if self.config_manager and not self.config_manager.telephone_enabled.get():
return
+ if self.web_audio_required:
+ from meshchatx.src.backend.web_audio_bridge import (
+ install_hostless_lxst_audio,
+ )
+
+ install_hostless_lxst_audio()
+
# Never enable LXST auto_answer. MeshChatX answers only via explicit
# user action or the separate voicemail timer after RINGING.
self.telephone = Telephone(self.identity, auto_answer=None)
diff --git a/meshchatx/src/backend/web_audio_bridge.py b/meshchatx/src/backend/web_audio_bridge.py
index c674c129..1e80ad9c 100644
--- a/meshchatx/src/backend/web_audio_bridge.py
+++ b/meshchatx/src/backend/web_audio_bridge.py
@@ -15,11 +15,157 @@ from LXST.Sources import LocalSource
from .telephone_manager import Tee
+_HOSTLESS_AUDIO_INSTALLED = False
+_ORIG_LINE_SOURCE = None
+_ORIG_LINE_SINK = None
+
def _log_debug(msg: str):
RNS.log(msg, RNS.LOG_DEBUG)
+class HostlessAudioSource(LocalSource):
+ """LineSource stand-in that never opens PulseAudio / host capture devices.
+
+ Docker headless and other web deployments have no usable host mic. LXST still
+ constructs LineSource during call setup before the websocket bridge attaches.
+ This class accepts the LineSource constructor shape and later yields to
+ WebAudioSource once a browser client connects.
+ """
+
+ def __init__(
+ self,
+ preferred_device=None,
+ target_frame_ms=60,
+ codec=None,
+ sink=None,
+ filters=None,
+ gain=0.0,
+ ease_in=0.0,
+ skip=0.0,
+ ):
+ self.preferred_device = preferred_device
+ self.target_frame_ms = target_frame_ms or 60
+ self.sink = sink
+ self.filters = filters
+ self.gain = gain
+ self.ease_in = ease_in
+ self.skip = skip
+ self.codec = codec or Raw(channels=1, bitdepth=16)
+ self.channels = 1
+ self.samplerate = 48000
+ self.bitdepth = 16
+ self.should_run = False
+ self.samples_per_frame = max(
+ 1,
+ int(round((self.target_frame_ms / 1000.0) * self.samplerate)),
+ )
+
+ def start(self):
+ self.should_run = True
+
+ def stop(self):
+ self.should_run = False
+
+ def can_receive(self, from_source=None):
+ return True
+
+ def handle_frame(self, frame, source=None):
+ pass
+
+ def push_pcm(self, pcm_bytes: bytes):
+ # Used if a bridge client attaches before WebAudioSource swap.
+ if not pcm_bytes or not self.sink:
+ return
+ try:
+ samples = (
+ np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
+ )
+ if samples.size == 0:
+ return
+ samples = samples.reshape(-1, 1)
+ frame = Raw(channels=1, bitdepth=16).encode(samples)
+ if self.sink.can_receive(from_source=self):
+ self.sink.handle_frame(frame, self)
+ except Exception as exc:
+ RNS.log(f"HostlessAudioSource: push_pcm failed: {exc}", RNS.LOG_ERROR)
+
+
+class HostlessAudioSink(LocalSink):
+ """LineSink stand-in that discards PCM when no host speaker exists."""
+
+ def __init__(self, preferred_device=None, autodigest=True, low_latency=False):
+ self.preferred_device = preferred_device
+ self.autodigest = autodigest
+ self.low_latency = low_latency
+ self.should_run = False
+ self.samplerate = 48000
+ self.channels = 1
+ self._wants_low_latency = False
+
+ def can_receive(self, from_source=None):
+ return True
+
+ def handle_frame(self, frame, source=None):
+ pass
+
+ def start(self):
+ self.should_run = True
+
+ def stop(self):
+ self.should_run = False
+
+ def enable_low_latency(self):
+ self._wants_low_latency = True
+
+
+def install_hostless_lxst_audio() -> bool:
+ """Replace LXST LineSource/LineSink with hostless stand-ins (idempotent)."""
+ global _HOSTLESS_AUDIO_INSTALLED, _ORIG_LINE_SOURCE, _ORIG_LINE_SINK
+ if _HOSTLESS_AUDIO_INSTALLED:
+ return True
+ try:
+ from LXST.Primitives import Telephony as lxst_telephony
+ except Exception as exc:
+ RNS.log(
+ f"WebAudioBridge: cannot install hostless audio backends: {exc}",
+ RNS.LOG_ERROR,
+ )
+ return False
+
+ _ORIG_LINE_SOURCE = getattr(lxst_telephony, "LineSource", None)
+ _ORIG_LINE_SINK = getattr(lxst_telephony, "LineSink", None)
+ lxst_telephony.LineSource = HostlessAudioSource
+ lxst_telephony.LineSink = HostlessAudioSink
+ _HOSTLESS_AUDIO_INSTALLED = True
+ RNS.log(
+ "WebAudioBridge: installed hostless LXST audio backends (no PulseAudio)",
+ RNS.LOG_INFO,
+ )
+ return True
+
+
+def hostless_lxst_audio_installed() -> bool:
+ return _HOSTLESS_AUDIO_INSTALLED
+
+
+def reset_hostless_lxst_audio_for_tests() -> None:
+ """Restore original LXST LineSource/LineSink (tests only)."""
+ global _HOSTLESS_AUDIO_INSTALLED, _ORIG_LINE_SOURCE, _ORIG_LINE_SINK
+ if not _HOSTLESS_AUDIO_INSTALLED:
+ return
+ with contextlib.suppress(Exception):
+ from LXST.Primitives import Telephony as lxst_telephony
+
+ if _ORIG_LINE_SOURCE is not None:
+ lxst_telephony.LineSource = _ORIG_LINE_SOURCE
+ if _ORIG_LINE_SINK is not None:
+ lxst_telephony.LineSink = _ORIG_LINE_SINK
+ _HOSTLESS_AUDIO_INSTALLED = False
+ _ORIG_LINE_SOURCE = None
+ _ORIG_LINE_SINK = None
+
+
class WebAudioSource(LocalSource):
"""Injects PCM frames (int16 little-endian) received over websocket into the transmit mixer."""
@@ -98,9 +244,10 @@ class WebAudioSink(LocalSink):
class WebAudioBridge:
"""Coordinates websocket audio transport with an active LXST telephone call."""
- def __init__(self, telephone_manager, config_manager):
+ def __init__(self, telephone_manager, config_manager, force_enabled: bool = False):
self.telephone_manager = telephone_manager
self.config_manager = config_manager
+ self.force_enabled = bool(force_enabled)
self.clients = set()
self.tx_source: WebAudioSource | None = None
self.rx_sink: WebAudioSink | None = None
@@ -127,6 +274,8 @@ class WebAudioBridge:
return getattr(self.telephone_manager, "telephone", None)
def config_enabled(self):
+ if self.force_enabled:
+ return True
return (
self.config_manager
and hasattr(self.config_manager, "telephone_web_audio_enabled")
@@ -134,12 +283,28 @@ class WebAudioBridge:
)
def allow_fallback(self):
+ if self.force_enabled:
+ # Never restore PulseAudio host paths on headless/web-required hosts.
+ return False
return (
self.config_manager
and hasattr(self.config_manager, "telephone_web_audio_allow_fallback")
and self.config_manager.telephone_web_audio_allow_fallback.get()
)
+ def get_diagnostics(self):
+ """Return a small status snapshot for /api/v1/telephone/status."""
+ tele = self._tele()
+ return {
+ "force_enabled": self.force_enabled,
+ "config_enabled": bool(self.config_enabled()),
+ "hostless_backends": hostless_lxst_audio_installed(),
+ "client_count": len(self.clients),
+ "has_tx_source": self.tx_source is not None,
+ "has_rx_sink": self.rx_sink is not None,
+ "has_active_call": bool(tele and getattr(tele, "active_call", None)),
+ }
+
def attach_client(self, client):
with self.lock:
tele = self._tele()
@@ -168,6 +333,7 @@ class WebAudioBridge:
{
"type": "web_audio.ready",
"frame_ms": frame_ms,
+ "required": self.force_enabled,
},
),
)
@@ -175,6 +341,17 @@ class WebAudioBridge:
def push_client_frame(self, pcm_bytes: bytes):
with self.lock:
if not self.tx_source:
+ # Hostless LineSource stand-in still accepts PCM until swap.
+ tele = self._tele()
+ audio_in = getattr(tele, "audio_input", None) if tele else None
+ if audio_in is not None and hasattr(audio_in, "push_pcm"):
+ if getattr(
+ self.telephone_manager,
+ "is_voicemail_session_active",
+ False,
+ ):
+ return
+ audio_in.push_pcm(pcm_bytes)
return
# Drop frames during voicemail
if getattr(self.telephone_manager, "is_voicemail_session_active", False):
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 1be4a5c0..c0edf635 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -17,6 +17,8 @@
:view-backend-logs-label="$t('app.view_backend_logs')"
:show-ws-reconnected="wsReconnectedBanner"
:ws-reconnected-label="$t('app.backend_reconnected')"
+ :show-network-starting="showNetworkStartingBanner"
+ :network-starting-label="$t('app.network_starting')"
:show-network-degraded="showNetworkDegradedBanner"
:network-degraded-label="networkDegradedBannerLabel"
:network-recovering="networkRecovering"
@@ -653,6 +655,7 @@ import {
BATTERY_SAVER_CHANGED_EVENT,
loadBatterySaverPrefs,
} from "../js/settings/batterySaverPrefs.js";
+import { setLocale } from "../js/localeLoader.js";
export default {
name: "App",
@@ -806,6 +809,14 @@ export default {
showNetworkDegradedBanner() {
return Boolean(GlobalState.networkDegraded) && this.$route?.name !== "auth";
},
+ showNetworkStartingBanner() {
+ return (
+ Boolean(GlobalState.networkStarting) &&
+ !GlobalState.networkDegraded &&
+ !GlobalState.networkReady &&
+ this.$route?.name !== "auth"
+ );
+ },
networkDegradedBannerLabel() {
const detail = GlobalState.networkDegradedError;
if (detail) {
@@ -841,7 +852,7 @@ export default {
config: {
handler(newConfig) {
if (newConfig && newConfig.language) {
- this.$i18n.locale = newConfig.language;
+ void this.applyLocale(newConfig.language);
}
if (newConfig && newConfig.custom_ringtone_enabled !== undefined) {
this.updateRingtonePlayer();
@@ -993,11 +1004,34 @@ export default {
}
const needShell = !GlobalState.authEnabled || (GlobalState.authenticated && this.$route.name !== "auth");
if (needShell && !this.shellRunning) {
+ if (GlobalState.networkStarting && !GlobalState.networkReady && !GlobalState.networkDegraded) {
+ this.waitForMeshThenStartShell();
+ return;
+ }
this.startShell();
} else if (!needShell && this.shellRunning) {
this.stopShell();
}
},
+ waitForMeshThenStartShell() {
+ if (this._meshWaitStarted) {
+ return;
+ }
+ this._meshWaitStarted = true;
+ const stopWatch = watch(
+ () => [GlobalState.networkReady, GlobalState.networkDegraded, GlobalState.networkStarting],
+ () => {
+ if (GlobalState.networkReady || GlobalState.networkDegraded || !GlobalState.networkStarting) {
+ stopWatch();
+ this._meshWaitStarted = false;
+ if (!this.shellRunning) {
+ this.applyShellAuthState();
+ }
+ }
+ },
+ { immediate: true }
+ );
+ },
startShell() {
if (this.shellRunning) {
return;
@@ -1808,6 +1842,16 @@ export default {
"theme"
);
},
+ async applyLocale(langCode) {
+ if (!langCode) {
+ return;
+ }
+ try {
+ await setLocale(this.$i18n, langCode);
+ } catch {
+ this.$i18n.locale = langCode;
+ }
+ },
async onLanguageChange(langCode) {
await this.updateConfig(
{
@@ -1815,7 +1859,7 @@ export default {
},
"language"
);
- this.$i18n.locale = langCode;
+ await this.applyLocale(langCode);
},
async composeNewMessage() {
// go to messages route
diff --git a/meshchatx/src/frontend/components/LanguageSelector.vue b/meshchatx/src/frontend/components/LanguageSelector.vue
index 7e2fe564..0d95b11d 100644
--- a/meshchatx/src/frontend/components/LanguageSelector.vue
+++ b/meshchatx/src/frontend/components/LanguageSelector.vue
@@ -44,18 +44,24 @@
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
+import { ensureLocaleMessages, listLocaleCodes } from "../js/localeLoader.js";
-const localeModules = import.meta.glob("../locales/*.json", { eager: true });
-const discoveredLanguages = Object.entries(localeModules)
- .map(([filePath, mod]) => ({
- code: filePath.match(/\/([^/]+)\.json$/)[1],
- name: mod.default?._languageName || filePath.match(/\/([^/]+)\.json$/)[1],
- }))
- .sort((a, b) => {
- if (a.code === "en") return -1;
- if (b.code === "en") return 1;
- return a.name.localeCompare(b.name);
- });
+const LANGUAGE_NAMES = {
+ de: "Deutsch",
+ en: "English",
+ es: "Español",
+ fi: "Suomi",
+ fr: "Français",
+ it: "Italiano",
+ nl: "Nederlands",
+ ru: "Русский",
+ zh: "中文",
+};
+
+const discoveredLanguages = listLocaleCodes().map((code) => ({
+ code,
+ name: LANGUAGE_NAMES[code] || code,
+}));
export default {
name: "LanguageSelector",
@@ -139,6 +145,11 @@ export default {
return;
}
+ try {
+ await ensureLocaleMessages(this.$i18n, langCode);
+ } catch {
+ // Locale pack may be unavailable in tests or offline shells.
+ }
this.$emit("language-change", langCode);
this.closeDropdown();
},
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 60fb346a..e76ed209 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -672,14 +672,21 @@
<div class="flex flex-col gap-1">
<Toggle
id="web-audio-toggle"
- :model-value="config?.telephone_web_audio_enabled"
+ :model-value="webAudioBridgeEnabled"
+ :disabled="webAudioBridgeRequired"
label="Web Audio Bridge"
@update:model-value="onToggleWebAudio"
/>
<div class="text-xs text-gray-500 dark:text-zinc-400 px-1">
- Web audio bridge allows web/electron to hook into LXST
- backend for passing microphone and audio streams to active
- telephone calls.
+ <template v-if="webAudioBridgeRequired">
+ Required on this host (no LXST host audio device).
+ Browser mic and speaker are used for calls.
+ </template>
+ <template v-else>
+ Web audio bridge allows web/electron to hook into LXST
+ backend for passing microphone and audio streams to
+ active telephone calls.
+ </template>
</div>
</div>
</div>
@@ -719,10 +726,7 @@
</div>
<!-- Web Audio Device Selection -->
- <div
- v-if="config?.telephone_web_audio_enabled"
- class="flex flex-col gap-2 mt-2"
- >
+ <div v-if="webAudioBridgeEnabled" class="flex flex-col gap-2 mt-2">
<div class="flex flex-col gap-1">
<div
class="text-[10px] font-bold text-gray-500 uppercase tracking-widest px-1"
@@ -2371,6 +2375,7 @@ export default {
return {
config: null,
activeCall: null,
+ webAudioBridgeRequired: false,
audioProfiles: [],
selectedAudioProfileId: null,
destinationHash: "",
@@ -2472,6 +2477,9 @@ export default {
};
},
computed: {
+ webAudioBridgeEnabled() {
+ return Boolean(this.webAudioBridgeRequired || this.config?.telephone_web_audio_enabled);
+ },
isMicMuted() {
return this.localMicMuted;
},
@@ -2906,10 +2914,9 @@ export default {
async disableWebAudioBridgeWithError(errorKey, error, stage = "unknown") {
this.logWebAudioFailure(stage, error);
ToastUtils.error(this.$t(errorKey));
- // On Android the backend forces web_audio.enabled while Chaquopy is
- // present. Permanently clearing the config flag just creates a 1s
- // retry/toast loop without helping recovery.
- if (!this.isMeshChatXAndroid()) {
+ // On Android / headless hosts the backend forces web audio. Permanently
+ // clearing the config flag just creates a retry/toast loop.
+ if (!this.isMeshChatXAndroid() && !this.webAudioBridgeRequired) {
if (this.config) {
this.config.telephone_web_audio_enabled = false;
}
@@ -2922,6 +2929,9 @@ export default {
this.stopWebAudio();
},
async ensureWebAudio(webAudioStatus) {
+ if (webAudioStatus && typeof webAudioStatus.required === "boolean") {
+ this.webAudioBridgeRequired = webAudioStatus.required;
+ }
if (!webAudioStatus?.enabled) {
this.stopWebAudio();
return;
@@ -2940,6 +2950,9 @@ export default {
},
async onToggleWebAudio(newVal) {
if (!this.config) return;
+ if (this.webAudioBridgeRequired && !newVal) {
+ return;
+ }
const previousValue = this.config.telephone_web_audio_enabled;
this.config.telephone_web_audio_enabled = newVal;
try {
@@ -3202,7 +3215,7 @@ export default {
if (msg.type === "error") {
const errMsg = typeof msg.message === "string" ? msg.message : "";
if (errMsg.includes("Web audio is disabled in config")) {
- if (this.config) {
+ if (!this.webAudioBridgeRequired && this.config) {
this.config.telephone_web_audio_enabled = false;
}
this.stopWebAudio();
diff --git a/meshchatx/src/frontend/components/filesync/RnsFilesyncPage.vue b/meshchatx/src/frontend/components/filesync/RnsFilesyncPage.vue
index b6a32f23..6a4b7c3b 100644
--- a/meshchatx/src/frontend/components/filesync/RnsFilesyncPage.vue
+++ b/meshchatx/src/frontend/components/filesync/RnsFilesyncPage.vue
@@ -120,7 +120,9 @@
<div class="flex flex-wrap gap-x-4 gap-y-1">
<span>
{{ $t("rns_filesync.running") }}:
- <strong>{{ status.running ? $t("rns_filesync.yes") : $t("rns_filesync.no") }}</strong>
+ <strong>{{
+ status.running ? $t("rns_filesync.yes") : $t("rns_filesync.no")
+ }}</strong>
</span>
<span>
{{ $t("rns_filesync.peers_count") }}:
@@ -302,9 +304,9 @@
{{ $t("rns_filesync.acl_grant") }}
</button>
</div>
- <pre
- class="p-3 rounded-lg bg-zinc-100 dark:bg-zinc-900 text-xs overflow-x-auto"
- >{{ aclRulesText }}</pre>
+ <pre class="p-3 rounded-lg bg-zinc-100 dark:bg-zinc-900 text-xs overflow-x-auto">{{
+ aclRulesText
+ }}</pre>
</div>
</div>
</div>
@@ -357,7 +359,7 @@ export default {
aclDelete: false,
aclRules: {},
lastProgress: "",
- _wsHandlers: [],
+ wsHandlers: [],
};
},
computed: {
@@ -374,16 +376,16 @@ export default {
await this.refreshAll();
},
beforeUnmount() {
- for (const [type, handler] of this._wsHandlers) {
+ for (const [type, handler] of this.wsHandlers) {
offWsEvent(type, handler);
}
- this._wsHandlers = [];
+ this.wsHandlers = [];
},
methods: {
bindWs() {
const bind = (type, handler) => {
onWsEvent(type, handler);
- this._wsHandlers.push([type, handler]);
+ this.wsHandlers.push([type, handler]);
};
bind("filesync.sync.progress", (payload) => {
this.lastProgress = JSON.stringify(payload);
@@ -406,18 +408,11 @@ export default {
});
bind("filesync.error", (payload) => {
const detail = payload?.error || payload?.message || "";
- ToastUtils.error(
- `${this.$t("rns_filesync.error")}${detail ? ": " + detail : ""}`,
- );
+ ToastUtils.error(`${this.$t("rns_filesync.error")}${detail ? ": " + detail : ""}`);
});
},
async refreshAll() {
- await Promise.all([
- this.refreshStatus(),
- this.refreshPeers(),
- this.refreshFiles(),
- this.refreshAcl(),
- ]);
+ await Promise.all([this.refreshStatus(), this.refreshPeers(), this.refreshFiles(), this.refreshAcl()]);
},
async refreshStatus() {
try {
diff --git a/meshchatx/src/frontend/components/layout/AppShellBanners.vue b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
index 89651039..f4de78ba 100644
--- a/meshchatx/src/frontend/components/layout/AppShellBanners.vue
+++ b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
@@ -45,6 +45,14 @@
>
{{ wsReconnectedLabel }}
</div>
+ <div
+ v-if="showNetworkStarting"
+ class="relative z-100 bg-sky-800 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-sky-900/80"
+ role="status"
+ aria-live="polite"
+ >
+ {{ networkStartingLabel }}
+ </div>
<div
v-if="showNetworkDegraded"
class="relative z-100 bg-amber-700 text-white px-4 py-3 text-center text-sm font-medium shadow-md border-b border-amber-800/80"
@@ -120,6 +128,14 @@ export default {
type: String,
default: "",
},
+ showNetworkStarting: {
+ type: Boolean,
+ default: false,
+ },
+ networkStartingLabel: {
+ type: String,
+ default: "",
+ },
showNetworkDegraded: {
type: Boolean,
default: false,
diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js
index 6de2c69d..bab283de 100644
--- a/meshchatx/src/frontend/js/GlobalState.js
+++ b/meshchatx/src/frontend/js/GlobalState.js
@@ -18,6 +18,8 @@ const globalState = reactive({
hasPendingInterfaceChanges: false,
networkDegraded: false,
networkDegradedError: null,
+ networkStarting: false,
+ networkReady: true,
config: {
show_unknown_contact_banner: true,
banished_effect_enabled: true,
diff --git a/meshchatx/src/frontend/js/localeLoader.js b/meshchatx/src/frontend/js/localeLoader.js
new file mode 100644
index 00000000..efb29bca
--- /dev/null
+++ b/meshchatx/src/frontend/js/localeLoader.js
@@ -0,0 +1,84 @@
+// SPDX-License-Identifier: 0BSD
+
+const localeModules = import.meta.glob("../locales/*.json");
+
+function resolveComposer(i18nOrComposer) {
+ if (!i18nOrComposer) {
+ return null;
+ }
+ return i18nOrComposer.global || i18nOrComposer;
+}
+
+/**
+ * Locale codes discovered from bundled JSON without loading message bodies.
+ * @returns {string[]}
+ */
+export function listLocaleCodes() {
+ return Object.keys(localeModules)
+ .map((filePath) => {
+ const match = filePath.match(/\/([^/]+)\.json$/);
+ return match ? match[1] : null;
+ })
+ .filter(Boolean)
+ .sort((a, b) => {
+ if (a === "en") {
+ return -1;
+ }
+ if (b === "en") {
+ return 1;
+ }
+ return a.localeCompare(b);
+ });
+}
+
+/**
+ * Load a locale message pack into vue-i18n when missing.
+ * @param {import("vue-i18n").I18n | import("vue-i18n").Composer} i18nOrComposer
+ * @param {string} code
+ * @returns {Promise<boolean>}
+ */
+export async function ensureLocaleMessages(i18nOrComposer, code) {
+ if (!code || typeof code !== "string") {
+ return false;
+ }
+ const composer = resolveComposer(i18nOrComposer);
+ if (!composer) {
+ return false;
+ }
+ if (composer.availableLocales?.includes(code)) {
+ return true;
+ }
+ if (typeof composer.setLocaleMessage !== "function") {
+ return false;
+ }
+ const loader = localeModules[`../locales/${code}.json`];
+ if (!loader) {
+ return false;
+ }
+ const mod = await loader();
+ composer.setLocaleMessage(code, mod.default || mod);
+ return true;
+}
+
+/**
+ * Apply a locale after ensuring its messages are loaded.
+ * @param {import("vue-i18n").I18n | import("vue-i18n").Composer} i18nOrComposer
+ * @param {string} code
+ * @returns {Promise<boolean>}
+ */
+export async function setLocale(i18nOrComposer, code) {
+ const ok = await ensureLocaleMessages(i18nOrComposer, code);
+ if (!ok) {
+ return false;
+ }
+ const composer = resolveComposer(i18nOrComposer);
+ if (!composer) {
+ return false;
+ }
+ if (composer.locale && typeof composer.locale === "object" && "value" in composer.locale) {
+ composer.locale.value = code;
+ } else {
+ composer.locale = code;
+ }
+ return true;
+}
diff --git a/meshchatx/src/frontend/js/networkStartupWait.js b/meshchatx/src/frontend/js/networkStartupWait.js
index aa9afd54..b73c4add 100644
--- a/meshchatx/src/frontend/js/networkStartupWait.js
+++ b/meshchatx/src/frontend/js/networkStartupWait.js
@@ -11,47 +11,63 @@ export const STARTUP_STAGE_LABELS = {
/**
* Interpret a /api/v1/status JSON body for boot gating.
+ * Only own properties are trusted so prototype pollution cannot spoof readiness.
* @param {unknown} data
- * @returns {{ kind: "ready" | "degraded" | "failed" | "starting" | "invalid", stage?: string, error?: string, label?: string }}
+ * @returns {{ kind: "ready" | "ui" | "degraded" | "failed" | "starting" | "invalid", stage?: string, error?: string, label?: string }}
*/
export function interpretStartupStatus(data) {
- if (!data || typeof data !== "object") {
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
return { kind: "invalid" };
}
- const status = data.status;
- const stage = typeof data.stage === "string" ? data.stage : undefined;
+ const own = (key) => Object.prototype.hasOwnProperty.call(data, key);
+ const status = own("status") ? data.status : undefined;
+ const stage = own("stage") && typeof data.stage === "string" ? data.stage : undefined;
+ const networkReady = own("network_ready") && data.network_ready === true;
+ const uiReady = own("ui_ready") && data.ui_ready === true;
+ const networkDegraded = own("network_degraded") && data.network_degraded === true;
+ const error = own("error") && typeof data.error === "string" ? data.error : undefined;
+
if (status === "failed") {
// HTTP is up and the backend marked UI as usable: mount the app in
// degraded mode so interfaces/settings remain reachable for recovery.
- if (data.ui_ready === true || data.network_degraded === true) {
+ if (uiReady || networkDegraded) {
return {
kind: "degraded",
stage: stage || "failed",
- error: typeof data.error === "string" ? data.error : undefined,
+ error,
};
}
return {
kind: "failed",
stage: stage || "failed",
- error: typeof data.error === "string" ? data.error : undefined,
+ error,
};
}
- if (status === "ok" || data.network_ready === true) {
+ if (status === "ok" || networkReady) {
return { kind: "ready", stage: stage || "ready" };
}
if (status === "starting" || status === undefined) {
const resolvedStage = stage || "starting";
+ const label = STARTUP_STAGE_LABELS[resolvedStage] || "Starting network…";
+ // HTTP is bound and the shell may mount while RNS/identity finish.
+ if (uiReady) {
+ return {
+ kind: "ui",
+ stage: resolvedStage,
+ label,
+ };
+ }
return {
kind: "starting",
stage: resolvedStage,
- label: STARTUP_STAGE_LABELS[resolvedStage] || "Starting network…",
+ label,
};
}
return { kind: "invalid", stage };
}
/**
- * Poll /api/v1/status until the network stack is ready or degraded-but-usable.
+ * Poll /api/v1/status until the UI may mount (ui_ready), mesh is ready, or degraded.
* @param {{
* fetchImpl?: typeof fetch,
* now?: () => number,
@@ -61,8 +77,9 @@ export function interpretStartupStatus(data) {
* onErrorState?: () => void,
* onDegraded?: (error?: string) => void,
* statusUrl?: string,
+ * mountOnUiReady?: boolean,
* }} [options]
- * @returns {Promise<"ready" | "degraded" | false>}
+ * @returns {Promise<"ready" | "ui" | "degraded" | false>}
*/
export async function waitForNetworkReady(options = {}) {
const fetchImpl = options.fetchImpl || fetch;
@@ -73,6 +90,7 @@ export async function waitForNetworkReady(options = {}) {
const onErrorState = options.onErrorState || (() => {});
const onDegraded = options.onDegraded || (() => {});
const statusUrl = options.statusUrl || "/api/v1/status";
+ const mountOnUiReady = options.mountOnUiReady !== false;
const deadline = now() + timeoutMs;
let delayMs = 200;
@@ -95,7 +113,11 @@ export async function waitForNetworkReady(options = {}) {
if (interpreted.kind === "ready") {
return "ready";
}
- if (interpreted.kind === "starting") {
+ if (interpreted.kind === "ui" && mountOnUiReady) {
+ onLine(interpreted.label || "Opening the app…");
+ return "ui";
+ }
+ if (interpreted.kind === "starting" || interpreted.kind === "ui") {
onLine(interpreted.label || "Getting things ready…");
}
}
@@ -109,3 +131,25 @@ export async function waitForNetworkReady(options = {}) {
onErrorState();
return false;
}
+
+/**
+ * Continue polling until mesh network_ready or degraded/failed.
+ * Used after an early UI mount on ui_ready.
+ * @param {{
+ * fetchImpl?: typeof fetch,
+ * now?: () => number,
+ * sleep?: (ms: number) => Promise<void>,
+ * timeoutMs?: number,
+ * onLine?: (text: string) => void,
+ * onErrorState?: () => void,
+ * onDegraded?: (error?: string) => void,
+ * statusUrl?: string,
+ * }} [options]
+ * @returns {Promise<"ready" | "degraded" | false>}
+ */
+export async function waitForMeshReady(options = {}) {
+ return waitForNetworkReady({
+ ...options,
+ mountOnUiReady: false,
+ });
+}
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 862918b5..a44b2f00 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -488,6 +488,7 @@
"rpc_key_hide": "RPC-Schlüssel verbergen",
"refresh_community_interfaces": "Von directory.rns.recipes aktualisieren",
"refresh_community_interfaces_busy": "Wird aktualisiert…",
+ "network_starting": "Verbinde mit dem Mesh…",
"network_degraded": "Mesh-Netzwerk nicht verfügbar. Die App läuft weiter, damit Sie Schnittstellen reparieren können, ohne Daten zu löschen.",
"recover_network": "Netzwerk erneut versuchen",
"open_interfaces": "Schnittstellen öffnen",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 46b8ee00..5b7d297e 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -410,6 +410,7 @@
"backend_process_stopped": "Reticulum backend stopped",
"backend_reconnected": "Reconnected to backend",
"restart_backend": "Restart backend",
+ "network_starting": "Connecting to the mesh…",
"network_degraded": "Mesh network unavailable. The app is still running so you can fix interfaces without wiping data.",
"recover_network": "Retry network",
"open_interfaces": "Open interfaces",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index daa4098c..aeb4328f 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -488,6 +488,7 @@
"rpc_key_hide": "Ocultar clave RPC",
"refresh_community_interfaces": "Actualizar desde directory.rns.recipes",
"refresh_community_interfaces_busy": "Actualizando…",
+ "network_starting": "Conectando a la mesh…",
"network_degraded": "Red mesh no disponible. La aplicación sigue en ejecución para que pueda reparar interfaces sin borrar datos.",
"recover_network": "Reintentar red",
"open_interfaces": "Abrir interfaces",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 5a01519c..b4a59548 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -488,6 +488,7 @@
"rpc_key_hide": "Piilota RPC-avain",
"refresh_community_interfaces": "Päivitä lähteestä directory.rns.recipes",
"refresh_community_interfaces_busy": "Päivitetään…",
+ "network_starting": "Yhdistetään mesh-verkkoon…",
"network_degraded": "Mesh-verkko ei ole käytettävissä. Sovellus pysyy käynnissä, jotta voit korjata liittymiä ilman tietojen tyhjennystä.",
"recover_network": "Yritä verkkoa uudelleen",
"open_interfaces": "Avaa liittymät",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 14259720..2c786a40 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -488,6 +488,7 @@
"rpc_key_hide": "Masquer la clé RPC",
"refresh_community_interfaces": "Actualiser depuis directory.rns.recipes",
"refresh_community_interfaces_busy": "Actualisation…",
+ "network_starting": "Connexion au mesh…",
"network_degraded": "Réseau mesh indisponible. L'application reste ouverte pour réparer les interfaces sans effacer les données.",
"recover_network": "Réessayer le réseau",
"open_interfaces": "Ouvrir les interfaces",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index ab2ee9aa..ffe57b73 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -488,6 +488,7 @@
"rpc_key_hide": "Nascondi chiave RPC",
"refresh_community_interfaces": "Aggiorna da directory.rns.recipes",
"refresh_community_interfaces_busy": "Aggiornamento…",
+ "network_starting": "Connessione alla mesh…",
"network_degraded": "Rete mesh non disponibile. L'app resta attiva così puoi riparare le interfacce senza cancellare i dati.",
"recover_network": "Riprova rete",
"open_interfaces": "Apri interfacce",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 40d844be..4c8a14ef 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -488,6 +488,7 @@
"rpc_key_hide": "RPC-sleutel verbergen",
"refresh_community_interfaces": "Vernieuwen vanaf directory.rns.recipes",
"refresh_community_interfaces_busy": "Bezig met vernieuwen…",
+ "network_starting": "Verbinden met het mesh…",
"network_degraded": "Mesh-netwerk niet beschikbaar. De app blijft draaien zodat je interfaces kunt herstellen zonder gegevens te wissen.",
"recover_network": "Netwerk opnieuw proberen",
"open_interfaces": "Interfaces openen",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index ff330a2a..3672a55a 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -488,6 +488,7 @@
"rpc_key_hide": "Скрыть RPC-ключ",
"refresh_community_interfaces": "Обновить из directory.rns.recipes",
"refresh_community_interfaces_busy": "Обновление…",
+ "network_starting": "Подключение к mesh…",
"network_degraded": "Mesh-сеть недоступна. Приложение продолжает работать, чтобы вы могли исправить интерфейсы без удаления данных.",
"recover_network": "Повторить сеть",
"open_interfaces": "Открыть интерфейсы",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index c6c2653b..0efcffa8 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -488,6 +488,7 @@
"rpc_key_hide": "隐藏 RPC 密钥",
"refresh_community_interfaces": "从 directory.rns.recipes 刷新",
"refresh_community_interfaces_busy": "正在刷新…",
+ "network_starting": "正在连接 mesh…",
"network_degraded": "Mesh 网络不可用。应用仍在运行,便于修复接口而无需清除数据。",
"recover_network": "重试网络",
"open_interfaces": "打开接口",
diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index e3cd48c2..e202371d 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -26,19 +26,15 @@ installWsEventBridge();
import App from "./components/App.vue";
import ChangelogModal from "./components/ChangelogModal.vue";
import TutorialModal from "./components/TutorialModal.vue";
-
-const localeModules = import.meta.glob("./locales/*.json", { eager: true });
-const messages = {};
-for (const filePath in localeModules) {
- const code = filePath.match(/\/([^/]+)\.json$/)[1];
- messages[code] = localeModules[filePath].default;
-}
+import enMessages from "./locales/en.json";
const i18n = createI18n({
legacy: false,
locale: "en",
fallbackLocale: "en",
- messages,
+ messages: {
+ en: enMessages,
+ },
});
// init vuetify
@@ -342,7 +338,7 @@ window.api = createApiClient({
},
});
-import { waitForNetworkReady } from "./js/networkStartupWait.js";
+import { waitForMeshReady, waitForNetworkReady } from "./js/networkStartupWait.js";
function setBootSplashLine(text) {
const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null;
@@ -365,11 +361,21 @@ const networkReady = await waitForNetworkReady({
onDegraded: (error) => {
GlobalState.networkDegraded = true;
GlobalState.networkDegradedError = error || "Mesh network unavailable";
+ GlobalState.networkStarting = false;
+ GlobalState.networkReady = false;
},
});
if (networkReady) {
if (networkReady === "degraded") {
GlobalState.networkDegraded = true;
+ GlobalState.networkStarting = false;
+ GlobalState.networkReady = false;
+ } else if (networkReady === "ui") {
+ GlobalState.networkStarting = true;
+ GlobalState.networkReady = false;
+ } else {
+ GlobalState.networkStarting = false;
+ GlobalState.networkReady = true;
}
try {
await fetchCsrfToken(window.api);
@@ -477,8 +483,38 @@ if (networkReady) {
});
});
preloadCriticalRouteChunks();
- void startCodec2ScriptsBackgroundLoad();
- void loadPluginsIfEnabled();
+ if (GlobalState.networkReady) {
+ void startCodec2ScriptsBackgroundLoad();
+ void loadPluginsIfEnabled();
+ } else if (GlobalState.networkStarting) {
+ void waitForMeshReady({
+ onLine: () => {},
+ onDegraded: (error) => {
+ GlobalState.networkDegraded = true;
+ GlobalState.networkDegradedError = error || "Mesh network unavailable";
+ GlobalState.networkStarting = false;
+ GlobalState.networkReady = false;
+ },
+ }).then((meshState) => {
+ if (meshState === "ready") {
+ GlobalState.networkStarting = false;
+ GlobalState.networkReady = true;
+ GlobalState.networkDegraded = false;
+ GlobalState.networkDegradedError = null;
+ void startCodec2ScriptsBackgroundLoad();
+ void loadPluginsIfEnabled();
+ } else if (meshState === "degraded") {
+ GlobalState.networkStarting = false;
+ GlobalState.networkReady = false;
+ } else {
+ GlobalState.networkStarting = false;
+ GlobalState.networkReady = false;
+ GlobalState.networkDegraded = true;
+ GlobalState.networkDegradedError =
+ GlobalState.networkDegradedError || "Mesh network startup timed out";
+ }
+ });
+ }
if (GlobalState.networkDegraded) {
try {
router.replace({ name: "interfaces" });
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/audio-calls.md b/meshchatx/src/frontend/public/meshchatx-docs/en/audio-calls.md
index c2ce38ce..1f0892a9 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/audio-calls.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/audio-calls.md
@@ -37,6 +37,8 @@ Voicemail events surface as `new_voicemail` on the WebSocket.
The **Call** area keeps history of placed, received, and missed calls. You can record calls when the feature is enabled and policy allows storage on your device.
+Unread missed calls show as a red count on the Calls sidebar icon and the header phone button. Opening the Call page clears that count. Desktop and Android still show a one-shot missed-call notification when the event happens.
+
## Ringtones
Upload custom ringtones and assign them per contact. Default sounds are used when no override exists.
@@ -79,6 +81,8 @@ Callee UI: ring, answer, or decline
- Use headphones on mobile and Quest builds to prevent echo.
- Review microphone permissions in Electron or the Android system settings if the UI shows no input level.
- Keep LXST and Reticulum versions aligned with MeshChatX release notes when upgrading.
+- **Docker / headless web**: containers have no PulseAudio host devices. MeshChatX forces the web audio bridge (`MESHCHAT_FORCE_WEB_AUDIO=1`) and installs hostless LXST backends so calls can use the browser mic/speaker. Enable telephone in settings, then place a call from the web UI over HTTPS.
+- **Android Codec2**: native `libcodec2.so` must be preloaded before `pycodec2`. If Codec2 profiles are hidden, check `/api/v1/telephone/codec2/status` and rebuild with vendor wheels that bundle `pycodec2/libcodec2.so`.
## See also
diff --git a/scripts/patch_lxst_codec2_optional.py b/scripts/patch_lxst_codec2_optional.py
new file mode 100644
index 00000000..f1b8e857
--- /dev/null
+++ b/scripts/patch_lxst_codec2_optional.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: 0BSD
+"""Make LXST Codec2 import optional so missing libcodec2 does not break telephony.
+
+Upstream LXST 0.5.x imports Codec2 unconditionally in Codecs/__init__.py.
+When pycodec2/libcodec2 is missing, that raises and prevents Opus profiles
+and the whole Telephone stack from loading. Android wheels already soft-import
+Codec2. This patch applies the same guard to a site-packages install.
+
+Idempotent. Safe to run after every uv sync / pip install.
+"""
+
+from __future__ import annotations
+
+import importlib.metadata
+import sys
+from pathlib import Path
+
+_CODECS_MARKER = "# meshchatx-lxst-codec2-optional (do not remove)\n"
+
+_CODECS_INIT = """# Copyright 2024-2026, Mark Qvist
+# meshchatx-lxst-codec2-optional (do not remove)
+
+from .Codec import CodecError as CodecError
+from .Codec import Codec as Codec
+from .Codec import Null as Null
+from .Raw import Raw as Raw
+from .Opus import Opus as Opus
+
+_CODEC2_IMPORT_ERROR = None
+try:
+ from .Codec2 import Codec2 as Codec2
+except Exception as _codec2_exc:
+ Codec2 = None
+ _CODEC2_IMPORT_ERROR = _codec2_exc
+
+NULL = 0xFF
+RAW = 0x00
+OPUS = 0x01
+CODEC2 = 0x02
+
+def _raise_codec2_unavailable():
+ if _CODEC2_IMPORT_ERROR is not None:
+ raise CodecError(f"Codec2 backend unavailable: {_CODEC2_IMPORT_ERROR}")
+ raise CodecError("Codec2 backend unavailable")
+
+def codec_header_byte(codec):
+ if codec == Raw:
+ return RAW.to_bytes()
+ elif codec == Opus:
+ return OPUS.to_bytes()
+ elif Codec2 is not None and codec == Codec2:
+ return CODEC2.to_bytes()
+
+ raise TypeError(f"No header mapping for codec type {codec}")
+
+def codec_type(header_byte):
+ if header_byte == RAW:
+ return Raw
+ elif header_byte == OPUS:
+ return Opus
+ elif header_byte == CODEC2:
+ if Codec2 is None:
+ _raise_codec2_unavailable()
+ return Codec2
+"""
+
+_OLD_GET_CODEC = """ @staticmethod
+ def get_codec(profile):
+ if profile == Profiles.BANDWIDTH_ULTRA_LOW: return Codec2(mode=Codec2.CODEC2_700C)
+ elif profile == Profiles.BANDWIDTH_VERY_LOW: return Codec2(mode=Codec2.CODEC2_1600)
+ elif profile == Profiles.BANDWIDTH_LOW: return Codec2(mode=Codec2.CODEC2_3200)
+ elif profile == Profiles.QUALITY_MEDIUM: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
+ elif profile == Profiles.QUALITY_HIGH: return Opus(profile=Opus.PROFILE_VOICE_HIGH)
+ elif profile == Profiles.QUALITY_MAX: return Opus(profile=Opus.PROFILE_VOICE_MAX)
+ elif profile == Profiles.LATENCY_LOW: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
+ elif profile == Profiles.LATENCY_ULTRA_LOW: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
+ else: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
+"""
+
+_NEW_GET_CODEC = """ @staticmethod
+ def get_codec(profile):
+ if Codec2 is not None:
+ if profile == Profiles.BANDWIDTH_ULTRA_LOW: return Codec2(mode=Codec2.CODEC2_700C)
+ elif profile == Profiles.BANDWIDTH_VERY_LOW: return Codec2(mode=Codec2.CODEC2_1600)
+ elif profile == Profiles.BANDWIDTH_LOW: return Codec2(mode=Codec2.CODEC2_3200)
+ if profile == Profiles.QUALITY_MEDIUM: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
+ elif profile == Profiles.QUALITY_HIGH: return Opus(profile=Opus.PROFILE_VOICE_HIGH)
+ elif profile == Profiles.QUALITY_MAX: return Opus(profile=Opus.PROFILE_VOICE_MAX)
+ elif profile == Profiles.LATENCY_LOW: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
+ elif profile == Profiles.LATENCY_ULTRA_LOW: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
+ else: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
+"""
+
+
+def _lxst_file(*parts: str) -> Path | None:
+ try:
+ dist = importlib.metadata.distribution("lxst")
+ except importlib.metadata.PackageNotFoundError:
+ return None
+ for rel in dist.files or ():
+ if tuple(rel.parts[-len(parts) :]) == parts:
+ return Path(dist.locate_file(rel))
+ return None
+
+
+def main() -> int:
+ codecs_init = _lxst_file("Codecs", "__init__.py")
+ telephony = _lxst_file("Primitives", "Telephony.py")
+ if codecs_init is None:
+ print(
+ "patch_lxst_codec2_optional: lxst not installed, nothing to do",
+ file=sys.stderr,
+ )
+ return 0
+
+ codecs_text = codecs_init.read_text(encoding="utf-8")
+ if _CODECS_MARKER not in codecs_text:
+ codecs_init.write_text(_CODECS_INIT, encoding="utf-8")
+ print(f"patch_lxst_codec2_optional: patched Codecs/__init__.py ({codecs_init})")
+ else:
+ print(f"patch_lxst_codec2_optional: Codecs already patched ({codecs_init})")
+
+ if telephony is None:
+ print(
+ "patch_lxst_codec2_optional: Telephony.py not found",
+ file=sys.stderr,
+ )
+ return 1
+
+ tel_text = telephony.read_text(encoding="utf-8")
+ if "if Codec2 is not None:" in tel_text and "BANDWIDTH_ULTRA_LOW" in tel_text:
+ print(
+ f"patch_lxst_codec2_optional: Telephony get_codec already guarded ({telephony})"
+ )
+ return 0
+
+ if _OLD_GET_CODEC not in tel_text:
+ print(
+ "patch_lxst_codec2_optional: unexpected Telephony get_codec layout; "
+ f"manual check required ({telephony})",
+ file=sys.stderr,
+ )
+ return 1
+
+ telephony.write_text(
+ tel_text.replace(_OLD_GET_CODEC, _NEW_GET_CODEC, 1),
+ encoding="utf-8",
+ )
+ print(f"patch_lxst_codec2_optional: patched Telephony get_codec ({telephony})")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/backend/test_auto_resend_regression.py b/tests/backend/test_auto_resend_regression.py
index b81bfb50..0e6b785d 100644
--- a/tests/backend/test_auto_resend_regression.py
+++ b/tests/backend/test_auto_resend_regression.py
@@ -98,9 +98,7 @@ def _bind_resend_app(db):
ctx = MagicMock()
ctx.identity.hash.hex.return_value = "aa" * 16
ctx.database = db
- ctx.config.allow_auto_resending_failed_messages_with_attachments.get.return_value = (
- True
- )
+ ctx.config.allow_auto_resending_failed_messages_with_attachments.get.return_value = True
return app, ctx
@@ -169,9 +167,7 @@ async def test_resend_skips_attachments_before_claim(db):
)
_insert_failed(db, msg_hash=msg, peer=peer, content="pic", fields=fields)
app, ctx = _bind_resend_app(db)
- ctx.config.allow_auto_resending_failed_messages_with_attachments.get.return_value = (
- False
- )
+ ctx.config.allow_auto_resending_failed_messages_with_attachments.get.return_value = False
await app.resend_failed_messages_for_destination(peer, context=ctx)
app.send_message.assert_not_called()
row = db.provider.fetchone(
@@ -257,10 +253,13 @@ async def test_resend_does_not_delete_old_when_send_returns_none(db):
app, ctx = _bind_resend_app(db)
app.send_message.return_value = None
await app.resend_failed_messages_for_destination(peer, context=ctx)
- assert db.provider.fetchone(
- "SELECT state FROM lxmf_messages WHERE hash = ?",
- (old_hash,),
- )["state"] == "failed"
+ assert (
+ db.provider.fetchone(
+ "SELECT state FROM lxmf_messages WHERE hash = ?",
+ (old_hash,),
+ )["state"]
+ == "failed"
+ )
# Cooldown claimed, attempt counted.
row = db.provider.fetchone(
"SELECT fields, next_delivery_attempt_at FROM lxmf_messages WHERE hash = ?",
diff --git a/tests/backend/test_deferred_network_startup.py b/tests/backend/test_deferred_network_startup.py
index cc23ff83..a1c17109 100644
--- a/tests/backend/test_deferred_network_startup.py
+++ b/tests/backend/test_deferred_network_startup.py
@@ -71,6 +71,7 @@ def test_deferred_init_skips_reticulum_until_background_setup(mock_identity, tem
payload = app._startup_status_payload()
assert payload["status"] == "starting"
assert payload["network_ready"] is False
+ assert payload["ui_ready"] is True
assert payload["stage"] == "http"
assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
@@ -266,6 +267,7 @@ def test_startup_status_payload_stages(mock_identity, temp_dir):
assert payload["status"] == "starting"
assert payload["stage"] == stage
assert payload["network_ready"] is False
+ assert payload["ui_ready"] is True
assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
diff --git a/tests/backend/test_discovered_interfaces_security.py b/tests/backend/test_discovered_interfaces_security.py
index f97e3ec0..a2a50671 100644
--- a/tests/backend/test_discovered_interfaces_security.py
+++ b/tests/backend/test_discovered_interfaces_security.py
@@ -78,12 +78,33 @@ def test_filter_discovered_interfaces_long_names_and_scripts():
max_size=8,
),
)
-def test_discovery_filter_candidates_never_raises(name, typ, host, port, extra):
+def test_discovery_filter_candidates_field_oracle(name, typ, host, port, extra):
iface = {"name": name, "type": typ, "reachable_on": host, "port": port, **extra}
c = ReticulumMeshChat.discovery_filter_candidates(iface)
assert isinstance(c, list)
for x in c:
assert isinstance(x, str)
+ for key in (
+ "name",
+ "type",
+ "reachable_on",
+ "target_host",
+ "remote",
+ "listen_ip",
+ "port",
+ "target_port",
+ "listen_port",
+ "discovery_hash",
+ "transport_id",
+ "network_id",
+ "network_name",
+ "ifac_netname",
+ ):
+ value = iface.get(key)
+ if value is not None and value != "":
+ assert str(value) in c
+ if host and port:
+ assert f"{host}:{port}" in c
@settings(
@@ -115,13 +136,19 @@ def test_discovery_filter_candidates_never_raises(name, typ, host, port, extra):
def test_matches_discovery_pattern_fuzzing(wl, bl, iface):
patterns_wl = ",".join(wl) if wl else ""
patterns_bl = ",".join(bl) if bl else ""
- ReticulumMeshChat.matches_discovery_pattern(
- ReticulumMeshChat.sanitize_discovery_patterns(patterns_wl),
- iface,
+ assert isinstance(
+ ReticulumMeshChat.matches_discovery_pattern(
+ ReticulumMeshChat.sanitize_discovery_patterns(patterns_wl),
+ iface,
+ ),
+ bool,
)
- ReticulumMeshChat.matches_discovery_pattern(
- ReticulumMeshChat.sanitize_discovery_patterns(patterns_bl),
- iface,
+ assert isinstance(
+ ReticulumMeshChat.matches_discovery_pattern(
+ ReticulumMeshChat.sanitize_discovery_patterns(patterns_bl),
+ iface,
+ ),
+ bool,
)
diff --git a/tests/backend/test_emergency_mode.py b/tests/backend/test_emergency_mode.py
index 3a36ea5c..d574e37c 100644
--- a/tests/backend/test_emergency_mode.py
+++ b/tests/backend/test_emergency_mode.py
@@ -95,6 +95,9 @@ def test_emergency_mode_startup_logic(mock_rns, temp_dir):
patch(
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
+ patch(
+ "meshchatx.src.backend.identity_context.IdentityContext.setup_deferred_services",
+ ),
):
# Initialize app in emergency mode
app = ReticulumMeshChat(
@@ -144,6 +147,9 @@ def test_emergency_mode_env_var(mock_rns, temp_dir):
patch(
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
+ patch(
+ "meshchatx.src.backend.identity_context.IdentityContext.setup_deferred_services",
+ ),
):
# We need to simulate the argparse processing that happens in main()
# but since we are testing ReticulumMeshChat directly, we check if it respects the flag
@@ -208,14 +214,15 @@ def test_normal_mode_startup_logic(mock_rns, temp_dir):
assert db_path_arg != ":memory:"
assert db_path_arg.endswith("database.db")
- # Verify IntegrityManager.check_integrity WAS called
- assert mock_integrity_instance.check_integrity.call_count == 1
+ # Critical integrity at core setup, full walk during deferred services
+ assert mock_integrity_instance.check_integrity.call_count >= 1
+ mock_integrity_instance.check_integrity.assert_any_call(critical_only=True)
# Verify TelephoneManager.init_telephone WAS called
mock_tel_instance = mock_tel_class.return_value
assert mock_tel_instance.init_telephone.call_count == 1
- # Verify IntegrityManager.save_manifest WAS called
+ # Manifest is saved after deferred integrity pass
assert mock_integrity_instance.save_manifest.call_count == 1
diff --git a/tests/backend/test_gif_utils.py b/tests/backend/test_gif_utils.py
index b9f77874..1adea7a6 100644
--- a/tests/backend/test_gif_utils.py
+++ b/tests/backend/test_gif_utils.py
@@ -158,6 +158,18 @@ def test_build_export_document_shape():
assert doc["exported_at"] == "2026-01-01T00:00:00Z"
+_GIF_REJECT_REASONS = frozenset(
+ {
+ "invalid_image_bytes",
+ "empty_image",
+ "image_too_large",
+ "invalid_image_type",
+ "invalid_image_signature",
+ "magic_type_mismatch",
+ },
+)
+
+
@settings(max_examples=200, deadline=None)
@given(
raw=st.binary(min_size=0, max_size=4096),
@@ -167,16 +179,35 @@ def test_build_export_document_shape():
st.sampled_from(["gif", "webp", "image/gif", "image/webp", "png", ""]),
),
)
-def test_validate_gif_payload_fuzz_never_raises_unexpected(raw, typ):
- """Fuzz: validation either succeeds or raises ValueError with known reasons."""
+def test_validate_gif_payload_accept_reject_oracle(raw, typ):
+ """Accept only when type and magic agree and size is in range."""
+ nt = gif_utils.normalize_image_type(typ)
+ detected = gif_utils.detect_image_format_from_magic(raw)
+ expect_ok = (
+ bool(raw)
+ and len(raw) <= gif_utils.MAX_GIF_BYTES
+ and nt is not None
+ and detected is not None
+ and detected == nt
+ )
try:
- gif_utils.validate_gif_payload(raw, typ)
- except ValueError:
- pass
+ out_type, out_hash = gif_utils.validate_gif_payload(raw, typ)
+ except ValueError as exc:
+ assert not expect_ok
+ assert str(exc) in _GIF_REJECT_REASONS
+ return
+ assert expect_ok
+ assert out_type == detected
+ assert len(out_hash) == 64
+ assert all(c in "0123456789abcdef" for c in out_hash)
@settings(max_examples=500, deadline=None)
@given(raw=st.binary(min_size=0, max_size=4096))
-def test_detect_image_format_from_magic_fuzz_never_raises(raw):
+def test_detect_image_format_from_magic_closed_set(raw):
out = gif_utils.detect_image_format_from_magic(raw)
assert out is None or out in {"gif", "webp"}
+ if raw.startswith((b"GIF87a", b"GIF89a")):
+ assert out == "gif"
+ elif len(raw) >= 12 and raw.startswith(b"RIFF") and raw[8:12] == b"WEBP":
+ assert out == "webp"
diff --git a/tests/backend/test_hex_identifier_utils.py b/tests/backend/test_hex_identifier_utils.py
index 51bd29c9..6c65eb91 100644
--- a/tests/backend/test_hex_identifier_utils.py
+++ b/tests/backend/test_hex_identifier_utils.py
@@ -39,14 +39,30 @@ def test_hex_identifier_to_bytes_invalid_returns_none():
assert hex_identifier_to_bytes("abc") is None
-@given(s=st.text())
-def test_normalize_hex_identifier_never_raises(s):
- normalize_hex_identifier(s)
-
-
-@given(s=st.text())
-def test_hex_identifier_to_bytes_never_raises(s):
- hex_identifier_to_bytes(s)
+@given(s=st.one_of(st.none(), st.text(), st.binary(), st.integers()))
+def test_normalize_hex_identifier_oracle(s):
+ """Output is only lowercase hex digits, empty for non-str or empty input."""
+ out = normalize_hex_identifier(s) # type: ignore[arg-type]
+ if not isinstance(s, str) or not s:
+ assert out == ""
+ return
+ assert isinstance(out, str)
+ assert all(c in "0123456789abcdef" for c in out)
+ assert out == "".join(c for c in s.strip().lower() if c in "0123456789abcdef")
+
+
+@given(s=st.one_of(st.none(), st.text(max_size=400)))
+def test_hex_identifier_to_bytes_oracle(s):
+ """Accept only even-length normalized hex. Reject odd or empty."""
+ n = normalize_hex_identifier(s)
+ b = hex_identifier_to_bytes(s)
+ if not n or len(n) % 2:
+ assert b is None
+ return
+ assert b is not None
+ assert isinstance(b, bytes)
+ assert len(b) == len(n) // 2
+ assert b.hex() == n
@given(h=st.from_regex(r"[0-9a-fA-F]{0,200}"))
diff --git a/tests/backend/test_integrity_critical_only.py b/tests/backend/test_integrity_critical_only.py
new file mode 100644
index 00000000..20caee6e
--- /dev/null
+++ b/tests/backend/test_integrity_critical_only.py
@@ -0,0 +1,73 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Extra integrity edge cases for critical-only startup checks."""
+
+from __future__ import annotations
+
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+
+from meshchatx.src.backend.integrity_manager import (
+ IntegrityManager,
+ select_critical_integrity_issues,
+)
+
+
+class TestCriticalOnlyIntegrity(unittest.TestCase):
+ def setUp(self):
+ self.test_dir = tempfile.mkdtemp()
+ self.db_path = Path(self.test_dir) / "database.db"
+ self.db_path.write_bytes(b"db-bytes")
+ self.identity_path = Path(self.test_dir) / "identity"
+ self.identity_path.write_bytes(b"identity-bytes")
+ self.manager = IntegrityManager(
+ self.test_dir,
+ str(self.db_path),
+ identity_hash="hash-a",
+ )
+ self.manager.save_manifest()
+
+ def tearDown(self):
+ shutil.rmtree(self.test_dir)
+
+ def test_critical_only_passes_unchanged_tree(self):
+ is_ok, issues = self.manager.check_integrity(critical_only=True)
+ self.assertTrue(is_ok)
+ self.assertEqual(select_critical_integrity_issues(issues), [])
+
+ def test_critical_only_detects_missing_identity(self):
+ self.identity_path.unlink()
+ is_ok, issues = self.manager.check_integrity(critical_only=True)
+ self.assertFalse(is_ok)
+ self.assertTrue(
+ select_critical_integrity_issues(issues)
+ or any("missing" in i.lower() for i in issues)
+ )
+
+ def test_critical_only_skips_non_critical_size_drift(self):
+ notes = Path(self.test_dir) / "notes.bin"
+ notes.write_bytes(b"aaaa")
+ self.manager.save_manifest()
+ notes.write_bytes(b"bbbbbbbb")
+ is_ok_critical, issues_critical = self.manager.check_integrity(
+ critical_only=True
+ )
+ self.assertTrue(
+ is_ok_critical or not select_critical_integrity_issues(issues_critical),
+ )
+ is_ok_full, issues_full = self.manager.check_integrity(critical_only=False)
+ self.assertFalse(is_ok_full)
+ self.assertTrue(any("notes.bin" in issue for issue in issues_full))
+
+ def test_select_critical_markers_cover_identity_and_db(self):
+ issues = [
+ "File signature mismatch: notes.bin",
+ "Critical security component integrity compromised: identity",
+ "Database structural issue: corrupt",
+ "Identity mismatch! Manifest belongs to: other",
+ ]
+ critical = select_critical_integrity_issues(issues)
+ self.assertEqual(len(critical), 3)
+ self.assertNotIn("File signature mismatch: notes.bin", critical)
diff --git a/tests/backend/test_lxst_telephony_adversarial.py b/tests/backend/test_lxst_telephony_adversarial.py
new file mode 100644
index 00000000..6087f3c3
--- /dev/null
+++ b/tests/backend/test_lxst_telephony_adversarial.py
@@ -0,0 +1,333 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Adversarial, property, exploratory, and oracle tests for LXST telephony / web audio.
+
+Oracles (invariants that must always hold):
+1. Hostless LineSource/LineSink construct without PulseAudio and never raise on start/stop.
+2. WebAudioSource never raises on arbitrary PCM and drops oversized frames.
+3. When web_audio is required, bridge.config_enabled is True even if config is False.
+4. Codec2-unavailable profile resolution never returns a Codec2 bandwidth profile.
+5. install_hostless_lxst_audio is idempotent.
+"""
+
+from __future__ import annotations
+
+import os
+from unittest.mock import MagicMock, patch
+
+import numpy as np
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+pytest.importorskip("LXST")
+
+from LXST.Primitives.Telephony import Profiles
+
+from meshchatx.src.backend.telephone_manager import TelephoneManager
+from meshchatx.src.backend.web_audio_bridge import (
+ HostlessAudioSink,
+ HostlessAudioSource,
+ WebAudioBridge,
+ WebAudioSource,
+ hostless_lxst_audio_installed,
+ install_hostless_lxst_audio,
+ reset_hostless_lxst_audio_for_tests,
+)
+
+CODEC2_PROFILES = {
+ Profiles.BANDWIDTH_ULTRA_LOW,
+ Profiles.BANDWIDTH_VERY_LOW,
+ Profiles.BANDWIDTH_LOW,
+}
+
+
+@pytest.fixture(autouse=True)
+def _reset_hostless():
+ reset_hostless_lxst_audio_for_tests()
+ yield
+ reset_hostless_lxst_audio_for_tests()
+
+
+# ---------------------------------------------------------------------------
+# Oracles: hostless backends
+# ---------------------------------------------------------------------------
+
+
+def test_oracle_hostless_source_matches_linesource_ctor_shape():
+ sink = MagicMock()
+ sink.can_receive.return_value = True
+ src = HostlessAudioSource(
+ preferred_device="default",
+ target_frame_ms=60,
+ codec=None,
+ sink=sink,
+ filters=[],
+ gain=-3.0,
+ ease_in=0.1,
+ skip=0.05,
+ )
+ src.start()
+ src.stop()
+ src.handle_frame(None, None)
+ assert src.samplerate == 48000
+ assert src.channels == 1
+ assert src.can_receive() is True
+
+
+def test_oracle_hostless_sink_never_needs_pulse():
+ sink = HostlessAudioSink(preferred_device=None)
+ sink.start()
+ sink.handle_frame(np.zeros((160, 1), dtype=np.float32), None)
+ sink.enable_low_latency()
+ sink.stop()
+ assert sink.can_receive() is True
+
+
+@given(pcm=st.binary(min_size=0, max_size=WebAudioSource.MAX_PCM_BYTES + 4096))
+@settings(max_examples=40, deadline=None)
+def test_exploratory_hostless_source_push_pcm_never_raises(pcm):
+ sink = MagicMock()
+ sink.can_receive.return_value = True
+ src = HostlessAudioSource(target_frame_ms=60, sink=sink)
+ src.push_pcm(pcm)
+
+
+def test_oracle_install_hostless_is_idempotent():
+ assert install_hostless_lxst_audio() is True
+ assert hostless_lxst_audio_installed() is True
+ assert install_hostless_lxst_audio() is True
+ from LXST.Primitives import Telephony as T
+
+ assert T.LineSource is HostlessAudioSource
+ assert T.LineSink is HostlessAudioSink
+
+
+def test_oracle_hostless_linesource_constructs_without_soundcard():
+ assert install_hostless_lxst_audio() is True
+ from LXST.Primitives import Telephony as T
+
+ src = T.LineSource(target_frame_ms=60, codec=None, sink=MagicMock())
+ assert isinstance(src, HostlessAudioSource)
+ sink = T.LineSink()
+ assert isinstance(sink, HostlessAudioSink)
+
+
+# ---------------------------------------------------------------------------
+# Oracles: web audio required / force_enabled
+# ---------------------------------------------------------------------------
+
+
+def test_oracle_force_enabled_overrides_config_false():
+ cfg = MagicMock()
+ cfg.telephone_web_audio_enabled.get.return_value = False
+ bridge = WebAudioBridge(None, cfg, force_enabled=True)
+ assert bridge.config_enabled() is True
+ assert bridge.allow_fallback() is False
+ diag = bridge.get_diagnostics()
+ assert diag["force_enabled"] is True
+ assert diag["config_enabled"] is True
+
+
+def test_oracle_force_disabled_follows_config():
+ cfg = MagicMock()
+ cfg.telephone_web_audio_enabled.get.return_value = False
+ bridge = WebAudioBridge(None, cfg, force_enabled=False)
+ assert bridge.config_enabled() is False
+
+
+@given(extra=st.integers(min_value=1, max_value=8192))
+@settings(max_examples=20, deadline=None)
+def test_oracle_web_audio_drops_oversized_pcm(extra):
+ sink = MagicMock()
+ sink.can_receive.return_value = True
+ src = WebAudioSource(target_frame_ms=60, sink=sink)
+ src.push_pcm(b"\x00" * (WebAudioSource.MAX_PCM_BYTES + extra))
+ sink.handle_frame.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# Oracles: Codec2 profile fallback
+# ---------------------------------------------------------------------------
+
+
+@given(
+ pid=st.sampled_from(
+ list(Profiles.available_profiles())
+ + [0, 1, 2, 99, -1, 255, None, "64", "nope"],
+ ),
+)
+@settings(max_examples=40, deadline=None)
+def test_property_resolve_profile_never_returns_codec2_when_unavailable(pid):
+ tm = TelephoneManager(identity=MagicMock())
+ with patch.object(TelephoneManager, "codec2_available", return_value=False):
+ resolved = tm.resolve_audio_profile_id(pid)
+ assert resolved in Profiles.available_profiles()
+ assert resolved not in CODEC2_PROFILES
+
+
+def test_oracle_codec2_available_false_on_android_probe_fail():
+ tm = TelephoneManager(identity=MagicMock())
+ with (
+ patch("meshchatx.android_codec2._is_chaquopy_android", return_value=True),
+ patch(
+ "meshchatx.android_codec2.probe_pycodec2", return_value=(False, "missing")
+ ),
+ ):
+ assert tm.codec2_available() is False
+
+
+# ---------------------------------------------------------------------------
+# Adversarial: telephone init with web_audio_required
+# ---------------------------------------------------------------------------
+
+
+def test_adversarial_init_telephone_installs_hostless_when_required(tmp_path):
+ cfg = MagicMock()
+ cfg.telephone_enabled.get.return_value = True
+ cfg.telephone_audio_profile_id.get.return_value = Profiles.DEFAULT_PROFILE
+ identity = MagicMock()
+ identity.hash = b"\x11" * 16
+
+ with patch("meshchatx.src.backend.telephone_manager.Telephone") as telephone_cls:
+ telephone = telephone_cls.return_value
+ tm = TelephoneManager(
+ identity=identity,
+ config_manager=cfg,
+ storage_dir=str(tmp_path),
+ )
+ tm.web_audio_required = True
+ tm.init_telephone()
+ assert hostless_lxst_audio_installed() is True
+ telephone.set_busy_tone_time.assert_called()
+ telephone.set_connect_timeout.assert_called()
+
+
+def test_adversarial_init_telephone_skips_hostless_when_not_required(tmp_path):
+ cfg = MagicMock()
+ cfg.telephone_enabled.get.return_value = True
+ cfg.telephone_audio_profile_id.get.return_value = Profiles.DEFAULT_PROFILE
+ identity = MagicMock()
+ identity.hash = b"\x22" * 16
+
+ with patch("meshchatx.src.backend.telephone_manager.Telephone"):
+ tm = TelephoneManager(
+ identity=identity,
+ config_manager=cfg,
+ storage_dir=str(tmp_path),
+ )
+ tm.web_audio_required = False
+ tm.init_telephone()
+ assert hostless_lxst_audio_installed() is False
+
+
+# ---------------------------------------------------------------------------
+# MeshChatX web_audio_required helper
+# ---------------------------------------------------------------------------
+
+
+def test_oracle_meshchat_web_audio_required_host_audio_unavailable():
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app._headless = True
+ app._host_audio_unavailable_cached = None
+ with (
+ patch("meshchatx.meshchat._is_chaquopy_android", return_value=False),
+ patch.object(
+ ReticulumMeshChat,
+ "_probe_host_audio_unavailable",
+ return_value=True,
+ ),
+ ):
+ assert app.web_audio_required() is True
+
+
+def test_oracle_meshchat_web_audio_required_env(monkeypatch):
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app._headless = False
+ app._host_audio_unavailable_cached = False
+ monkeypatch.setenv("MESHCHAT_FORCE_WEB_AUDIO", "1")
+ with patch("meshchatx.meshchat._is_chaquopy_android", return_value=False):
+ assert app.web_audio_required() is True
+ monkeypatch.delenv("MESHCHAT_FORCE_WEB_AUDIO", raising=False)
+
+
+def test_oracle_meshchat_web_audio_not_required_when_host_audio_ok():
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app._headless = True
+ app._host_audio_unavailable_cached = False
+ os.environ.pop("MESHCHAT_FORCE_WEB_AUDIO", None)
+ with patch("meshchatx.meshchat._is_chaquopy_android", return_value=False):
+ assert app.web_audio_required() is False
+
+
+def test_oracle_headless_alone_does_not_force_web_audio():
+ """Frozen Electron uses --headless but still has host speakers/mic."""
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app._headless = True
+ app._host_audio_unavailable_cached = None
+ os.environ.pop("MESHCHAT_FORCE_WEB_AUDIO", None)
+ with (
+ patch("meshchatx.meshchat._is_chaquopy_android", return_value=False),
+ patch.object(
+ ReticulumMeshChat,
+ "_probe_host_audio_unavailable",
+ return_value=False,
+ ),
+ ):
+ assert app.web_audio_required() is False
+
+
+# ---------------------------------------------------------------------------
+# Android codec2 candidate discovery
+# ---------------------------------------------------------------------------
+
+
+def test_android_codec2_honours_explicit_path(tmp_path, monkeypatch):
+ from meshchatx import android_codec2
+
+ lib = tmp_path / "libcodec2.so"
+ lib.write_bytes(b"\x7fELF")
+ monkeypatch.setenv("MESHCHAT_LIBCODEC2_PATH", str(lib))
+ candidates = android_codec2._libcodec2_candidates()
+ assert lib.resolve() in [c.resolve() for c in candidates]
+
+
+def test_android_codec2_native_lib_dir(tmp_path, monkeypatch):
+ from meshchatx import android_codec2
+
+ native = tmp_path / "arm64-v8a"
+ native.mkdir()
+ lib = native / "libcodec2.so"
+ lib.write_bytes(b"\x7fELF")
+ monkeypatch.setenv("MESHCHAT_NATIVE_LIB_DIR", str(native))
+ candidates = android_codec2._libcodec2_candidates()
+ assert lib.resolve() in [c.resolve() for c in candidates]
+
+
+# ---------------------------------------------------------------------------
+# Bridge push through hostless audio_input before WebAudioSource swap
+# ---------------------------------------------------------------------------
+
+
+def test_push_client_frame_uses_hostless_audio_input_before_swap():
+ tele = MagicMock()
+ tele.active_call = object()
+ hostless = HostlessAudioSource(target_frame_ms=60, sink=MagicMock())
+ hostless.sink.can_receive.return_value = True
+ tele.audio_input = hostless
+ tele_mgr = MagicMock()
+ tele_mgr.telephone = tele
+ tele_mgr.is_voicemail_session_active = False
+ bridge = WebAudioBridge(tele_mgr, MagicMock(), force_enabled=True)
+ bridge.tx_source = None
+ pcm = np.zeros(80, dtype=np.int16).tobytes()
+ bridge.push_client_frame(pcm)
+ hostless.sink.handle_frame.assert_called()
diff --git a/tests/backend/test_phased_startup_guards.py b/tests/backend/test_phased_startup_guards.py
new file mode 100644
index 00000000..9c297846
--- /dev/null
+++ b/tests/backend/test_phased_startup_guards.py
@@ -0,0 +1,413 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Guards, races, and adversarial cases for phased / early-UI startup."""
+
+from __future__ import annotations
+
+import json
+import shutil
+import tempfile
+import threading
+import time
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+import RNS
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from meshchatx.meshchat import ReticulumMeshChat
+from meshchatx.src.backend.identity_context import IdentityContext
+from meshchatx.src.backend.integrity_manager import (
+ IntegrityManager,
+ select_critical_integrity_issues,
+)
+from tests.backend.api_json_contract_schemas import (
+ API_V1_STATUS_SCHEMA,
+ assert_matches_schema,
+)
+
+
+@pytest.fixture
+def temp_dir():
+ dir_path = tempfile.mkdtemp()
+ yield dir_path
+ shutil.rmtree(dir_path)
+
+
+@pytest.fixture
+def mock_identity():
+ identity = MagicMock(spec=RNS.Identity)
+ identity.hash = b"test_hash_32_bytes_long_01234567"
+ identity.hexhash = identity.hash.hex()
+ return identity
+
+
+def _make_deferred_app(mock_identity, temp_dir, **kwargs):
+ with (
+ patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
+ patch.object(ReticulumMeshChat, "setup_identity"),
+ ):
+ return ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ defer_network_setup=True,
+ **kwargs,
+ )
+
+
+def _status_oracle(payload: dict) -> None:
+ """Invariant oracle for /api/v1/status style payloads."""
+ assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
+ assert payload["network_ready"] is False or payload["status"] == "ok"
+ if payload["status"] == "ok":
+ assert payload["network_ready"] is True
+ assert payload["ui_ready"] is True
+ assert payload["network_degraded"] is False
+ if payload["status"] == "failed":
+ assert payload["network_ready"] is False
+ assert payload["ui_ready"] is True
+ assert payload["network_degraded"] is True
+ if payload["status"] == "starting":
+ assert payload["network_ready"] is False
+ assert payload["ui_ready"] is True
+
+
+def test_deferred_init_ui_ready_before_network(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ payload = app._startup_status_payload()
+ _status_oracle(payload)
+ assert payload["ui_ready"] is True
+ assert payload["network_ready"] is False
+
+
+def test_status_oracle_across_stage_transitions(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ for stage in ("http", "starting", "rns", "identity"):
+ app._set_startup_stage(stage)
+ _status_oracle(app._startup_status_payload())
+ app._mark_network_degraded("boom")
+ _status_oracle(app._startup_status_payload())
+ context = MagicMock()
+ context.running = True
+ app.current_context = context
+ app._mark_network_ready()
+ _status_oracle(app._startup_status_payload())
+
+
+def test_finish_deferred_calls_context_and_reticulum_secondary(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ ctx = MagicMock()
+ ctx.running = True
+ app.current_context = ctx
+ app._reticulum_secondary_started = False
+ with patch.object(app, "_start_deferred_reticulum_services") as start_rns:
+ app._finish_deferred_startup_services()
+ ctx.setup_deferred_services.assert_called_once_with()
+ start_rns.assert_called_once_with()
+
+
+def test_require_rns_tool_handler_503_while_deferred_pending(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ ctx = MagicMock()
+ ctx.running = True
+ app.current_context = ctx
+ app._mark_network_ready()
+ response = app._require_rns_tool_handler(None, "RNCP")
+ assert response is not None
+ assert response.status == 503
+ body = json.loads(response.body)
+ assert "RNCP" in body["message"]
+ assert body["network_ready"] is True
+
+
+def test_deferred_setup_idempotent_under_concurrent_calls(temp_dir):
+ app = MagicMock()
+ app.emergency = False
+ app.storage_dir = temp_dir
+ app.reticulum_config_dir = temp_dir
+ app.get_public_path = MagicMock(return_value=temp_dir)
+ app.integrity_issues = []
+ app.database_health_issues = []
+
+ identity = MagicMock()
+ identity.hash = b"abcdef0123456789abcdef0123456789"
+
+ ctx = IdentityContext.__new__(IdentityContext)
+ ctx.app = app
+ ctx.identity = identity
+ ctx.identity_hash = identity.hash.hex()
+ ctx.storage_path = temp_dir
+ ctx.database_path = str(Path(temp_dir) / "database.db")
+ ctx.running = True
+ ctx._deferred_setup_done = False
+ ctx._deferred_setup_lock = threading.Lock()
+ ctx._deferred_setup_in_progress = False
+ ctx._deferred_setup_finished = threading.Event()
+ ctx._deferred_setup_finished.set()
+ ctx.config = MagicMock()
+ ctx.config.rrc_enabled.get.return_value = False
+ ctx.config.libretranslate_url.get.return_value = ""
+ ctx.config.libretranslate_api_key.get.return_value = ""
+ ctx.config.translator_argos_enabled.get.return_value = False
+ ctx.config.translator_libretranslate_enabled.get.return_value = False
+ ctx.database = MagicMock()
+ ctx.docs_manager = MagicMock()
+ ctx.integrity_manager = MagicMock()
+ ctx.integrity_manager.check_integrity.return_value = (True, [])
+
+ calls = []
+ gate = threading.Event()
+ entered = threading.Event()
+
+ def slow_body():
+ calls.append("body")
+ entered.set()
+ gate.wait(timeout=2)
+
+ ctx._run_deferred_services_body = slow_body
+
+ threads = [threading.Thread(target=ctx.setup_deferred_services) for _ in range(8)]
+ for thread in threads:
+ thread.start()
+ assert entered.wait(timeout=2)
+ gate.set()
+ for thread in threads:
+ thread.join(timeout=2)
+ assert len(calls) == 1
+ assert ctx._deferred_setup_done is True
+ assert ctx._deferred_setup_in_progress is False
+
+
+def test_deferred_setup_aborts_when_torn_down_mid_flight(temp_dir):
+ app = MagicMock()
+ app.emergency = True
+ app.storage_dir = temp_dir
+ app.get_public_path = MagicMock(return_value=temp_dir)
+
+ identity = MagicMock()
+ identity.hash = b"abcdef0123456789abcdef0123456789"
+
+ ctx = IdentityContext.__new__(IdentityContext)
+ ctx.app = app
+ ctx.identity = identity
+ ctx.identity_hash = identity.hash.hex()
+ ctx.storage_path = temp_dir
+ ctx.running = True
+ ctx._deferred_setup_done = False
+ ctx._deferred_setup_lock = threading.Lock()
+ ctx._deferred_setup_in_progress = False
+ ctx._deferred_setup_finished = threading.Event()
+ ctx._deferred_setup_finished.set()
+ ctx.rncp_handler = None
+
+ started = threading.Event()
+ release = threading.Event()
+
+ def body():
+ started.set()
+ release.wait(timeout=2)
+ if not ctx.running:
+ return
+ ctx.rncp_handler = MagicMock(name="zombie-handler")
+
+ ctx._run_deferred_services_body = body
+
+ worker = threading.Thread(target=ctx.setup_deferred_services)
+ worker.start()
+ assert started.wait(timeout=2)
+ ctx.running = False
+ release.set()
+ worker.join(timeout=2)
+ assert ctx._deferred_setup_done is False
+ assert ctx.rncp_handler is None
+
+
+def test_teardown_waits_for_in_flight_deferred_setup(temp_dir):
+ ctx = IdentityContext.__new__(IdentityContext)
+ ctx.identity_hash = "abc"
+ ctx.running = True
+ ctx._deferred_setup_done = False
+ ctx._deferred_setup_lock = threading.Lock()
+ ctx._deferred_setup_in_progress = False
+ ctx._deferred_setup_finished = threading.Event()
+ ctx._deferred_setup_finished.set()
+
+ started = threading.Event()
+ release = threading.Event()
+
+ def body():
+ started.set()
+ release.wait(timeout=2)
+
+ ctx._run_deferred_services_body = body
+
+ worker = threading.Thread(target=ctx.setup_deferred_services)
+ worker.start()
+ assert started.wait(timeout=2)
+ assert ctx._deferred_setup_finished.is_set() is False
+
+ waiter_done = threading.Event()
+
+ def wait_like_teardown():
+ ctx.running = False
+ assert ctx._deferred_setup_finished.wait(timeout=2)
+ waiter_done.set()
+
+ waiter = threading.Thread(target=wait_like_teardown)
+ waiter.start()
+ time.sleep(0.05)
+ assert waiter_done.is_set() is False
+ release.set()
+ waiter.join(timeout=2)
+ worker.join(timeout=2)
+ assert waiter_done.is_set()
+ assert ctx._deferred_setup_in_progress is False
+ assert ctx._deferred_setup_done is False
+
+
+def test_critical_integrity_blocks_on_identity_file_tamper(temp_dir):
+ storage = Path(temp_dir)
+ db_path = storage / "database.db"
+ db_path.write_bytes(b"ok-db")
+ identity_path = storage / "identity"
+ identity_path.write_bytes(b"secret-key-material")
+
+ manager = IntegrityManager(str(storage), str(db_path), identity_hash="abc123")
+ manager.save_manifest()
+
+ identity_path.write_bytes(b"TAMPERED")
+ is_ok, issues = manager.check_integrity(critical_only=True)
+ assert is_ok is False
+ critical = select_critical_integrity_issues(issues)
+ assert critical
+ assert any("identity" in issue.lower() for issue in critical)
+
+
+def test_critical_integrity_ignores_non_critical_new_files(temp_dir):
+ storage = Path(temp_dir)
+ db_path = storage / "database.db"
+ db_path.write_bytes(b"ok-db")
+ (storage / "identity").write_bytes(b"secret")
+
+ manager = IntegrityManager(str(storage), str(db_path), identity_hash="abc123")
+ manager.save_manifest()
+
+ (storage / "notes.txt").write_text("benign", encoding="utf-8")
+ is_ok_critical, issues_critical = manager.check_integrity(critical_only=True)
+ assert is_ok_critical is True or not select_critical_integrity_issues(
+ issues_critical
+ )
+
+ is_ok_full, issues_full = manager.check_integrity(critical_only=False)
+ assert is_ok_full is False
+ assert any("New file detected" in issue for issue in issues_full)
+
+
+def test_critical_integrity_identity_mismatch_still_raises_marker(temp_dir):
+ storage = Path(temp_dir)
+ db_path = storage / "database.db"
+ db_path.write_bytes(b"ok-db")
+ (storage / "identity").write_bytes(b"secret")
+
+ manager = IntegrityManager(str(storage), str(db_path), identity_hash="original")
+ manager.save_manifest()
+
+ other = IntegrityManager(str(storage), str(db_path), identity_hash="other-hash")
+ is_ok, issues = other.check_integrity(critical_only=True)
+ assert is_ok is False
+ assert select_critical_integrity_issues(issues)
+
+
+@given(
+ stage=st.sampled_from(["http", "starting", "rns", "identity", "ready"]),
+ ui_ready=st.booleans(),
+ network_ready=st.booleans(),
+ degraded=st.booleans(),
+)
+@settings(max_examples=40, deadline=None)
+def test_status_payload_never_claims_ready_without_context(
+ stage,
+ ui_ready,
+ network_ready,
+ degraded,
+):
+ dir_path = tempfile.mkdtemp()
+ try:
+ identity = MagicMock(spec=RNS.Identity)
+ identity.hash = b"test_hash_32_bytes_long_01234567"
+ identity.hexhash = identity.hash.hex()
+ app = _make_deferred_app(identity, dir_path)
+ app._startup_stage = stage
+ app._ui_ready = ui_ready
+ app._network_ready = network_ready
+ app._network_degraded = degraded
+ if degraded:
+ app._startup_error = "x"
+ app._startup_stage = "failed"
+ payload = app._startup_status_payload()
+ if payload["status"] == "ok":
+ assert app.current_context is not None and app.current_context.running
+ assert payload["network_ready"] is True
+ if payload["network_ready"] is True:
+ assert payload["status"] == "ok"
+ assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
+ finally:
+ shutil.rmtree(dir_path)
+
+
+def test_background_setup_marks_ready_before_deferred_finishes(mock_identity, temp_dir):
+ ready_seen = threading.Event()
+ deferred_started = threading.Event()
+ release_deferred = threading.Event()
+
+ def fake_setup(self, identity):
+ context = MagicMock()
+ context.running = True
+ context.config = MagicMock()
+ context.config.auth_session_secret.set = MagicMock()
+
+ def deferred():
+ deferred_started.set()
+ release_deferred.wait(timeout=2)
+
+ context.setup_deferred_services = deferred
+ self.current_context = context
+ ready_seen.set()
+
+ with (
+ patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
+ patch("meshchatx.meshchat.AsyncUtils.run_async"),
+ patch.object(ReticulumMeshChat, "setup_identity", fake_setup),
+ patch.object(ReticulumMeshChat, "_start_deferred_reticulum_services"),
+ ):
+ app = ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ defer_network_setup=True,
+ )
+ app.session_secret_key = "secret"
+ app.start_network_setup_in_background()
+ assert ready_seen.wait(timeout=5)
+ assert app.wait_until_network_ready(timeout=5)
+ payload = app._startup_status_payload()
+ assert payload["network_ready"] is True
+ assert deferred_started.wait(timeout=2)
+ # Mesh is already advertised ready while deferred is still blocked.
+ assert release_deferred.is_set() is False
+ release_deferred.set()
+
+
+def test_reticulum_secondary_idempotent(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ app.reticulum = MagicMock()
+ app.plugins_enabled = False
+ app.page_node_manager = MagicMock()
+ app.page_node_manager.nodes = {}
+ app.sideband_plugin_loader = MagicMock()
+ app._start_deferred_reticulum_services()
+ app._start_deferred_reticulum_services()
+ app.page_node_manager.start_all.assert_called_once()
diff --git a/tests/backend/test_rncp_handler_extended.py b/tests/backend/test_rncp_handler_extended.py
index 530fe0fd..1e976d21 100644
--- a/tests/backend/test_rncp_handler_extended.py
+++ b/tests/backend/test_rncp_handler_extended.py
@@ -5,6 +5,8 @@ import shutil
from unittest.mock import MagicMock, patch
import pytest
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
from meshchatx.src.backend.rncp_handler import RNCPHandler
@@ -289,6 +291,33 @@ def test_fetch_request_jail_serves_file_inside_jail(rncp_handler, tmp_path):
assert _CapturingResource.served_path == str(shared)
+def test_fetch_request_jail_blocks_null_byte(rncp_handler, tmp_path):
+ """Null bytes in fetch paths are denied, not raised."""
+ jail = tmp_path / "share"
+ jail.mkdir()
+ rncp_handler.fetch_jail = str(jail)
+ _CapturingResource.served_path = None
+ link = _FakeLink(link_id=b"link-null")
+ with (
+ patch("meshchatx.src.backend.rncp_handler.RNS.Transport") as transport,
+ patch(
+ "meshchatx.src.backend.rncp_handler.RNS.Resource",
+ _CapturingResource,
+ ),
+ ):
+ transport.active_links = [link]
+ result = rncp_handler._fetch_request(
+ path="fetch_file",
+ data="\0../x",
+ request_id=None,
+ link_id=link.link_id,
+ remote_identity=None,
+ requested_at=0,
+ )
+ assert result == RNCPHandler.REQ_FETCH_NOT_ALLOWED
+ assert _CapturingResource.served_path is None
+
+
@patch("meshchatx.src.backend.rncp_handler.RNS.Identity")
@patch("meshchatx.src.backend.rncp_handler.RNS.Destination")
@patch("meshchatx.src.backend.rncp_handler.RNS.Reticulum")
@@ -363,3 +392,106 @@ def test_fetch_resource_concluded_sets_resolved_on_save_error(rncp_handler, tmp_
assert resource_status["value"] == "error"
assert save_error["value"]
os.chmod(effective_save_path, 0o700)
+
+
+_TRAVERSAL_PATHS = (
+ "../etc/passwd",
+ "../../host_secret.key",
+ "/etc/passwd",
+ "....//....//evil",
+ "share/../../outside.txt",
+ "\0../x",
+)
+
+
+@settings(
+ max_examples=80,
+ deadline=None,
+ suppress_health_check=[HealthCheck.function_scoped_fixture],
+)
+@given(
+ data=st.one_of(
+ st.sampled_from(_TRAVERSAL_PATHS),
+ st.text(min_size=0, max_size=200),
+ ),
+)
+def test_fetch_request_path_oracle(rncp_handler, tmp_path, data):
+ """Fetch never serves outside the jail. Success only for files inside it."""
+ jail = tmp_path / "share"
+ jail.mkdir(exist_ok=True)
+ inside = jail / "ok.txt"
+ if not inside.exists():
+ inside.write_text("ok")
+ outside = tmp_path / "host_secret.key"
+ if not outside.exists():
+ outside.write_text("PRIVATE")
+ rncp_handler.fetch_jail = str(jail)
+ _CapturingResource.served_path = None
+
+ link = _FakeLink(link_id=b"link-oracle")
+ with (
+ patch("meshchatx.src.backend.rncp_handler.RNS.Transport") as transport,
+ patch(
+ "meshchatx.src.backend.rncp_handler.RNS.Resource",
+ _CapturingResource,
+ ),
+ ):
+ transport.active_links = [link]
+ result = rncp_handler._fetch_request(
+ path="fetch_file",
+ data=data,
+ request_id=None,
+ link_id=link.link_id,
+ remote_identity=None,
+ requested_at=0,
+ )
+
+ assert result in (
+ True,
+ False,
+ None,
+ RNCPHandler.REQ_FETCH_NOT_ALLOWED,
+ )
+ jail_real = os.path.realpath(str(jail))
+ outside_real = os.path.realpath(str(outside))
+ if result is True:
+ served = _CapturingResource.served_path
+ assert served is not None
+ served_real = os.path.realpath(served)
+ assert served_real == jail_real or served_real.startswith(jail_real + os.sep)
+ assert served_real != outside_real
+ else:
+ assert _CapturingResource.served_path is None
+
+
+@settings(
+ max_examples=80,
+ deadline=None,
+ suppress_health_check=[HealthCheck.function_scoped_fixture],
+)
+@given(
+ file_path=st.one_of(
+ st.sampled_from(_TRAVERSAL_PATHS + ("identity", "identity.bak", "")),
+ st.text(min_size=0, max_size=200),
+ ),
+)
+def test_resolve_send_path_oracle(rncp_handler, file_path):
+ """Resolved send paths stay under storage or home and never expose identity keys."""
+ storage = os.path.realpath(rncp_handler.storage_dir)
+ home = os.path.expanduser("~")
+ home_real = os.path.realpath(home) if home and home != "~" else None
+ try:
+ real = rncp_handler._resolve_send_path(file_path)
+ except (ValueError, PermissionError, FileNotFoundError, OSError, TypeError):
+ return
+ assert isinstance(real, str)
+ real = os.path.realpath(real)
+ under_storage = real == storage or real.startswith(storage + os.sep)
+ under_home = home_real is not None and (
+ real == home_real or real.startswith(home_real + os.sep)
+ )
+ assert under_storage or under_home
+ assert os.path.basename(real) not in {"identity", "identity.bak"}
+ parts = {part for part in real.split(os.sep) if part}
+ assert not (parts & {".ssh", ".gnupg"})
+ assert os.path.isfile(real)
diff --git a/tests/backend/test_rns_filesync_handler.py b/tests/backend/test_rns_filesync_handler.py
index ff781faf..f5310004 100644
--- a/tests/backend/test_rns_filesync_handler.py
+++ b/tests/backend/test_rns_filesync_handler.py
@@ -36,11 +36,12 @@ def test_default_status_not_running(handler, mock_identity):
def test_update_settings_persists_sync_directory(handler):
- result = handler.update_settings(sync_directory="/tmp/filesync-alt", monitor=False)
+ nested = f"{handler.storage_dir}/filesync/alt"
+ result = handler.update_settings(sync_directory=nested, monitor=False)
assert result["ok"] is True
assert result["monitor"] is False
status = handler.get_status()
- assert status["sync_directory"] == "/tmp/filesync-alt"
+ assert status["sync_directory"] == nested
assert status["monitor"] is False
diff --git a/tests/backend/test_rns_filesync_security.py b/tests/backend/test_rns_filesync_security.py
index bcefc5b6..6d43e16d 100644
--- a/tests/backend/test_rns_filesync_security.py
+++ b/tests/backend/test_rns_filesync_security.py
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: 0BSD
-"""Adversarial and Hypothesis fuzz coverage for RNS FileSync handler."""
+"""Adversarial regression and Hypothesis fuzz coverage for RNS FileSync."""
from __future__ import annotations
@@ -14,7 +14,6 @@ from hypothesis import strategies as st
from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
from rns_filesync.paths import PathJailError, normalize_relpath
-from rns_filesync.permissions import PermissionStore
_TRAVERSAL_PAYLOADS = (
"../etc/passwd",
@@ -45,6 +44,7 @@ _BAD_HASHES = (
"' OR '1'='1",
"../../identity",
"а" * 32,
+ "all",
)
@@ -66,16 +66,22 @@ def test_path_traversal_payloads_rejected_by_normalize():
normalize_relpath(payload)
-def test_download_rejects_path_traversal(handler):
- handler.service = MagicMock()
- handler.service.download_file.side_effect = lambda peer, path: {
- "ok": False,
- "error": f"path jail: {path}",
- }
+def test_download_rejects_path_traversal_before_service(handler):
+ """Handler must jail paths itself, not wait for peer connectivity."""
+ service = MagicMock()
+ handler.service = service
for payload in _TRAVERSAL_PAYLOADS:
+ if not str(payload).strip():
+ result = handler.download_file("bb" * 16, payload)
+ assert result["ok"] is False
+ assert result["error"] == "path is required"
+ service.download_file.assert_not_called()
+ continue
+ service.reset_mock()
result = handler.download_file("bb" * 16, payload)
- assert result["ok"] is False
+ assert result["ok"] is False, payload
assert "error" in result
+ service.download_file.assert_not_called()
def test_download_and_browse_require_running(handler):
@@ -85,63 +91,89 @@ def test_download_and_browse_require_running(handler):
@pytest.mark.parametrize("bad_hash", _BAD_HASHES)
-def test_connect_rejects_or_handles_bad_hashes(handler, bad_hash):
+def test_connect_rejects_bad_hashes(handler, bad_hash):
handler.service = MagicMock()
- handler.service.connect_peer.return_value = {"ok": False, "error": "bad peer"}
result = handler.connect_peer(bad_hash)
- assert isinstance(result, dict)
- assert "ok" in result
- if not str(bad_hash).strip():
- assert result["ok"] is False
+ assert result["ok"] is False
+ assert "invalid" in result["error"] or "required" in result["error"]
+ handler.service.connect_peer.assert_not_called()
-@pytest.mark.parametrize("bad_hash", _BAD_HASHES)
-def test_acl_grant_handles_bad_hashes(handler, bad_hash):
+@pytest.mark.parametrize("bad_hash", [h for h in _BAD_HASHES if h != "all"])
+def test_acl_grant_rejects_bad_hashes(handler, bad_hash):
result = handler.update_acl(
identity_hash=bad_hash,
perms=["read"],
enforce=True,
)
- assert isinstance(result, dict)
- assert "ok" in result
+ assert result["ok"] is False
+ assert "invalid" in result["error"]
+ acl = handler.get_acl()
+ assert bad_hash not in acl["rules"].get("read", [])
+ assert "../escape" not in acl["rules"].get("read", [])
-def test_acl_enforce_denies_stranger_by_default_rules(handler):
+def test_acl_grant_accepts_all_alias(handler):
+ result = handler.update_acl(identity_hash="all", perms=["read"], enforce=True)
+ assert result["ok"] is True
+ assert "all" in result["rules"]["read"]
+ assert handler.get_acl()["rules"]["read"] == ["all"]
+
+
+def test_acl_enforce_false_persists_across_reload(tmp_path):
+ storage = tmp_path / "id"
+ storage.mkdir()
+ identity = SimpleNamespace(hash=b"\xaa" * 16)
peer = "cc" * 16
- stranger = "dd" * 16
- handler.update_acl(identity_hash=peer, perms=["read"], enforce=True)
- acl = handler.get_acl()
- assert acl["enforce"] is True
- perms = PermissionStore()
- for rule_perm, targets in acl["rules"].items():
- for target in targets:
- short = {"read": "r", "write": "w", "delete": "d"}[rule_perm]
- perms.add_rule(f"{short}:{target}")
- assert perms.check(peer, "read") is True
- assert perms.check(stranger, "read") is False
- assert perms.check(stranger, "write") is False
-
-
-def test_acl_rules_text_replace_does_not_leak_old_rules(handler):
- peer_a = "11" * 16
- peer_b = "22" * 16
- handler.update_acl(identity_hash=peer_a, perms=["read", "write"], enforce=True)
- handler.update_acl(
- rules_text=f"r:{peer_b}",
- replace=True,
- enforce=True,
+ first = RnsFilesyncHandler(MagicMock(), identity, str(storage))
+ first.update_acl(identity_hash=peer, perms=["read"], enforce=True)
+ disabled = first.update_acl(enforce=False)
+ assert disabled["ok"] is True
+ assert disabled["enforce"] is False
+ assert first.get_acl()["enforce"] is False
+ assert peer in first.get_acl()["rules"]["read"]
+
+ second = RnsFilesyncHandler(MagicMock(), identity, str(storage))
+ acl = second.get_acl()
+ assert acl["enforce"] is False
+ assert peer in acl["rules"]["read"]
+
+
+def test_acl_get_matches_update_result(handler):
+ peer = "dd" * 16
+ updated = handler.update_acl(
+ identity_hash=peer, perms=["read", "write"], enforce=True
+ )
+ fetched = handler.get_acl()
+ assert updated["enforce"] is True
+ assert fetched["enforce"] is True
+ assert fetched["rules"] == updated["rules"]
+ assert peer in fetched["rules"]["read"]
+ assert peer in fetched["rules"]["write"]
+
+
+def test_sync_directory_cannot_escape_identity_storage(handler, tmp_path):
+ outside = tmp_path / "other_identity" / "filesync" / "sync"
+ outside.mkdir(parents=True)
+ result = handler.update_settings(sync_directory=str(outside))
+ assert result["ok"] is False
+ assert "identity storage" in result["error"]
+
+ nested = handler.storage_dir + "/filesync/custom"
+ ok = handler.update_settings(sync_directory=nested)
+ assert ok["ok"] is True
+ assert ok["sync_directory"] == nested or ok["sync_directory"].endswith(
+ "/filesync/custom",
)
- acl = handler.get_acl()
- assert peer_a not in acl["rules"].get("read", [])
- assert peer_b in acl["rules"].get("read", [])
- assert peer_a not in acl["rules"].get("write", [])
-def test_oversized_rules_text_does_not_raise(handler):
- huge = ("r:" + ("ab" * 16) + "\n") * 5000
- result = handler.update_acl(rules_text=huge, replace=True, enforce=True)
- assert result["ok"] is True
- assert isinstance(result["rules"], dict)
+def test_start_rejects_escaped_sync_directory(handler, tmp_path):
+ outside = tmp_path / "escape_me"
+ outside.mkdir()
+ with patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService") as mocked:
+ result = handler.start(sync_directory=str(outside))
+ assert result["ok"] is False
+ mocked.assert_not_called()
def test_teardown_clears_service_and_isolates_storage(tmp_path):
@@ -164,14 +196,11 @@ def test_teardown_clears_service_and_isolates_storage(tmp_path):
assert ha.service is None
svc.stop.assert_called()
- assert "read" in ha.get_acl()["rules"]
assert peer in ha.get_acl()["rules"]["read"]
- assert "write" in hb.get_acl()["rules"]
assert peer in hb.get_acl()["rules"]["write"]
assert peer not in ha.get_acl()["rules"].get("write", [])
assert (storage_a / "filesync" / "acl.txt").is_file()
assert (storage_b / "filesync" / "acl.txt").is_file()
- assert storage_a.resolve() != storage_b.resolve()
def test_concurrent_acl_mutations_stable(handler):
@@ -187,8 +216,8 @@ def test_concurrent_acl_mutations_stable(handler):
enforce=True,
)
acl = handler.get_acl()
- assert isinstance(acl["rules"], dict)
assert acl["enforce"] is True
+ assert peer in acl["rules"]["read"] or peer in acl["rules"]["write"]
except BaseException as exc:
errors.append(exc)
@@ -198,34 +227,33 @@ def test_concurrent_acl_mutations_stable(handler):
for t in threads:
t.join(timeout=10)
assert not errors
- final = handler.get_acl()
- assert final["enforce"] is True
@settings(
- max_examples=40,
+ max_examples=50,
deadline=None,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
)
-@given(
- path=st.text(
- alphabet=st.characters(
- whitelist_categories=("L", "N", "P", "S", "Z", "Cc"),
- ),
- min_size=0,
- max_size=400,
- ),
-)
-def test_download_path_fuzz_never_raises(handler, path):
- handler.service = MagicMock()
- handler.service.download_file.side_effect = lambda peer, p: {
- "ok": False,
- "error": "denied",
- "path": p,
- }
+@given(path=st.sampled_from(list(_TRAVERSAL_PAYLOADS) + ["ok.txt", "dir/file.bin"]))
+def test_download_path_oracle(handler, path):
+ service = MagicMock()
+ service.download_file.return_value = {"ok": True, "path": path}
+ handler.service = service
result = handler.download_file("aa" * 16, path)
assert isinstance(result, dict)
assert "ok" in result
+ if not str(path).strip():
+ assert result["ok"] is False
+ service.download_file.assert_not_called()
+ return
+ try:
+ normalize_relpath(path)
+ except PathJailError:
+ assert result["ok"] is False
+ service.download_file.assert_not_called()
+ else:
+ assert result["ok"] is True
+ service.download_file.assert_called_once()
@settings(
@@ -235,35 +263,36 @@ def test_download_path_fuzz_never_raises(handler, path):
)
@given(
identity_hash=st.one_of(
- st.text(min_size=0, max_size=80),
- st.binary(min_size=0, max_size=40).map(lambda b: b.hex()),
st.sampled_from(list(_BAD_HASHES)),
+ st.from_regex(r"[0-9a-f]{32}", fullmatch=True),
+ st.text(min_size=0, max_size=40),
),
perms=st.lists(
st.sampled_from(["read", "write", "delete", "admin", "nope", ""]),
max_size=6,
),
- enforce=st.booleans(),
- rules_text=st.one_of(st.none(), st.text(max_size=500)),
- replace=st.booleans(),
)
-def test_acl_update_fuzz_never_raises(
- handler,
- identity_hash,
- perms,
- enforce,
- rules_text,
- replace,
-):
+def test_acl_hash_oracle(handler, identity_hash, perms):
+ effective_perms = perms if perms else ["read"]
result = handler.update_acl(
identity_hash=identity_hash,
- perms=perms,
- enforce=enforce,
- rules_text=rules_text,
- replace=replace,
+ perms=effective_perms,
+ enforce=True,
)
assert isinstance(result, dict)
assert "ok" in result
+ cleaned = str(identity_hash or "").strip().lower().replace(":", "")
+ valid = cleaned == "all" or (
+ len(cleaned) == 32 and all(c in "0123456789abcdef" for c in cleaned)
+ )
+ if not valid:
+ assert result["ok"] is False
+ return
+ if not any(p in ("read", "write", "delete") for p in effective_perms):
+ assert result["ok"] is False
+ return
+ assert result["ok"] is True
+ assert result["rules"] == handler.get_acl()["rules"]
@settings(
@@ -272,39 +301,26 @@ def test_acl_update_fuzz_never_raises(
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
)
@given(
- sync_directory=st.one_of(
- st.none(),
- st.text(min_size=0, max_size=200),
- st.sampled_from(["", " ", "../escape", "/tmp/x", "~/.ssh"]),
- ),
- monitor=st.one_of(st.none(), st.booleans()),
- announce_interval=st.one_of(
- st.none(),
- st.integers(min_value=-10, max_value=10_000),
- st.text(max_size=20),
+ sync_directory=st.sampled_from(
+ [
+ "",
+ " ",
+ "../escape",
+ "/tmp/x",
+ "~/.ssh",
+ "filesync/nested",
+ "filesync/../filesync/ok",
+ ],
),
)
-def test_settings_fuzz_never_raises(handler, sync_directory, monitor, announce_interval):
- result = handler.update_settings(
- sync_directory=sync_directory,
- monitor=monitor,
- announce_interval=announce_interval,
- )
+def test_settings_path_oracle(handler, sync_directory):
+ before = handler.get_status()["sync_directory"]
+ result = handler.update_settings(sync_directory=sync_directory)
assert isinstance(result, dict)
- assert "ok" in result
-
-
-@settings(
- max_examples=25,
- deadline=None,
- suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
-)
-@given(peer_id=st.text(min_size=0, max_size=100))
-def test_disconnect_fuzz_never_raises(handler, peer_id):
- handler.service = MagicMock()
- result = handler.disconnect_peer(peer_id)
- assert isinstance(result, dict)
- assert "ok" in result
+ if result["ok"]:
+ assert result["sync_directory"].startswith(handler.storage_dir)
+ else:
+ assert handler.get_status()["sync_directory"] == before
@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")
diff --git a/tests/backend/test_rrc_protocol_fuzz.py b/tests/backend/test_rrc_protocol_fuzz.py
index f7abe485..ed39e043 100644
--- a/tests/backend/test_rrc_protocol_fuzz.py
+++ b/tests/backend/test_rrc_protocol_fuzz.py
@@ -32,13 +32,19 @@ def _envelope_dict_strategy():
@given(data=st.binary(min_size=0, max_size=4096))
@settings(max_examples=200, deadline=None)
-def test_decode_random_bytes_never_raises_unexpected(data):
+def test_decode_random_bytes_accept_reject_oracle(data):
try:
obj = proto.decode(data)
except Exception as exc:
assert isinstance(exc, (cbor2.CBORDecodeError, ValueError, TypeError, EOFError))
return
- assert obj is not None or obj is None
+ # Successful decode yields a value. Prefer round-trip when CBOR can encode it.
+ try:
+ encoded = cbor2.dumps(obj)
+ except cbor2.CBOREncodeError:
+ return
+ assert isinstance(encoded, (bytes, bytearray))
+ assert proto.decode(encoded) == obj
@given(env=_envelope_dict_strategy())
@@ -58,7 +64,7 @@ def test_encode_decode_roundtrip_dict(env):
nick=st.one_of(st.none(), st.text(max_size=64)),
)
@settings(max_examples=120, deadline=None)
-def test_make_envelope_encode_decode_never_raises(msg_type, src, room, body, nick):
+def test_make_envelope_encode_decode_roundtrip(msg_type, src, room, body, nick):
env = proto.make_envelope(msg_type, src, room=room, body=body, nick=nick)
decoded = proto.decode(proto.encode(env))
assert decoded[proto.K_T] == int(msg_type)
@@ -67,12 +73,15 @@ def test_make_envelope_encode_decode_never_raises(msg_type, src, room, body, nic
@given(nick=st.text(max_size=128))
@settings(max_examples=100, deadline=None)
-def test_normalize_nick_never_raises(nick):
+def test_normalize_nick_shape_oracle(nick):
result = proto.normalize_nick(nick)
if result is not None:
assert isinstance(result, str)
assert result.strip() == result
assert len(result.encode("utf-8")) <= proto.DEFAULT_MAX_NICK_BYTES
+ stripped = (nick or "").strip()
+ if not stripped:
+ assert result is None
@given(nick=st.text(max_size=128), max_bytes=st.integers(min_value=1, max_value=256))
@@ -96,14 +105,17 @@ def test_normalize_room_only_raises_on_empty_or_whitespace(room):
@given(text=st.text(max_size=256), nick=st.text(max_size=64))
@settings(max_examples=120, deadline=None)
-def test_text_mentions_never_raises(text, nick):
+def test_text_mentions_bool_oracle(text, nick):
result = proto.text_mentions(text, nick)
assert isinstance(result, bool)
+ n = proto.normalize_nick(nick)
+ if n is None:
+ assert result is False
@given(text=st.one_of(st.none(), st.integers(), st.binary(), st.text(max_size=256)))
@settings(max_examples=80, deadline=None)
-def test_parse_who_notice_never_raises(text):
+def test_parse_who_notice_shape_oracle(text):
result = proto.parse_who_notice(text)
if result is not None:
room, entries = result
@@ -114,7 +126,7 @@ def test_parse_who_notice_never_raises(text):
@given(text=st.one_of(st.none(), st.integers(), st.binary(), st.text(max_size=512)))
@settings(max_examples=80, deadline=None)
-def test_parse_room_list_notice_never_raises(text):
+def test_parse_room_list_notice_shape_oracle(text):
result = proto.parse_room_list_notice(text)
if result is not None:
assert isinstance(result, dict)
@@ -133,19 +145,18 @@ def test_parse_room_list_notice_never_raises(text):
ts=st.one_of(st.integers(), st.floats(allow_nan=False), st.text(max_size=16)),
)
@settings(max_examples=100, deadline=None)
-def test_rrc_message_to_dict_never_raises(kind, room, src, nick, text, ts):
+def test_rrc_message_to_dict_shape_oracle(kind, room, src, nick, text, ts):
msg = proto.RRCMessage(kind, room, src, nick, text, ts)
d = msg.to_dict()
assert isinstance(d, dict)
- assert "kind" in d
- assert "text" in d
+ assert d["kind"] == kind
assert isinstance(d["text"], str)
assert isinstance(d["ts"], int)
@given(app_data=st.one_of(st.none(), st.text(max_size=256), st.binary(max_size=128)))
@settings(max_examples=100, deadline=None)
-def test_display_name_from_hub_app_data_never_raises(app_data):
+def test_display_name_from_hub_app_data_shape_oracle(app_data):
if isinstance(app_data, bytes):
b64 = base64.b64encode(app_data).decode("ascii")
elif isinstance(app_data, str):
diff --git a/tests/backend/test_security_fuzzing.py b/tests/backend/test_security_fuzzing.py
index 1a499ee4..361a6bb5 100644
--- a/tests/backend/test_security_fuzzing.py
+++ b/tests/backend/test_security_fuzzing.py
@@ -3,7 +3,6 @@
import base64
import math
import os
-import time
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
@@ -1116,63 +1115,16 @@ def test_archive_page_content_fuzzing(mock_app, destination_hash, page_path, con
mock_app.archiver_manager.get_archived_page_versions(destination_hash, page_path)
-@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
-@given(
- file_path=st.text(min_size=0, max_size=1000),
-)
-def test_rncp_file_path_traversal_fuzzing(mock_app, file_path):
- """Fuzz RNCP file path handling for directory traversal."""
- try:
- import asyncio
-
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- loop.run_until_complete(
- mock_app.rncp_handler.send_file(
- os.urandom(16),
- file_path,
- timeout=1.0,
- ),
- )
- except Exception:
- pass
-
-
-@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
-@given(
- path=st.text(min_size=0, max_size=1000),
- data=st.one_of(st.text(), st.binary()),
- request_id=st.one_of(st.integers(), st.text()),
-)
-def test_rncp_fetch_request_path_fuzzing(mock_app, path, data, request_id):
- """Fuzz RNCP fetch request path handling."""
- try:
- mock_identity = MagicMock()
- mock_identity.hash = os.urandom(16)
- mock_app.rncp_handler._fetch_request(
- path,
- data,
- request_id,
- os.urandom(16),
- mock_identity,
- time.time(),
- )
- except Exception:
- pass
-
-
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
app_data_base64=st.text(min_size=0, max_size=10000),
)
-def test_parse_lxmf_stamp_cost_fuzzing(mock_app, app_data_base64):
- """Fuzz LXMF stamp cost parsing from base64 app_data."""
- try:
- from meshchatx.src.backend.meshchat_utils import parse_lxmf_stamp_cost
+def test_parse_lxmf_stamp_cost_oracle(mock_app, app_data_base64):
+ """Stamp cost parse returns None or a non-negative number. Never raises."""
+ from meshchatx.src.backend.meshchat_utils import parse_lxmf_stamp_cost
- parse_lxmf_stamp_cost(app_data_base64)
- except Exception:
- pass
+ cost = parse_lxmf_stamp_cost(app_data_base64)
+ assert cost is None or (isinstance(cost, (int, float)) and cost >= 0)
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
diff --git a/tests/backend/test_sticker_utils.py b/tests/backend/test_sticker_utils.py
index 1ee4bd06..b76f3ae0 100644
--- a/tests/backend/test_sticker_utils.py
+++ b/tests/backend/test_sticker_utils.py
@@ -147,6 +147,40 @@ def test_mime_for_image_type():
assert sticker_utils.mime_for_image_type("unknown") == "application/octet-stream"
+_STICKER_REJECT_REASONS = frozenset(
+ {
+ "invalid_image_bytes",
+ "empty_image",
+ "invalid_image_type",
+ "invalid_image_signature",
+ "magic_type_mismatch",
+ "image_too_large",
+ "animated_too_large",
+ "animated_dimensions_invalid",
+ "animated_fps_out_of_range",
+ "animated_duration_too_long",
+ "video_too_large",
+ "video_has_audio",
+ "video_codec_not_vp9",
+ "video_fps_too_high",
+ "video_duration_too_long",
+ "static_too_large",
+ "static_dimensions_unknown",
+ "strict_format_not_supported",
+ },
+)
+
+_STICKER_EXPORT_PREFIXES = (
+ "invalid_document",
+ "invalid_format",
+ "unsupported_version",
+ "invalid_stickers_array",
+ "invalid_sticker_at_",
+ "missing_image_bytes_at_",
+ "invalid_base64_at_",
+)
+
+
@settings(max_examples=200, deadline=None)
@given(
raw=st.binary(min_size=0, max_size=sticker_utils.MAX_STICKER_BYTES + 1),
@@ -158,19 +192,44 @@ def test_mime_for_image_type():
),
),
)
-def test_validate_sticker_payload_fuzz_never_raises_unexpected(raw, typ):
- """Fuzz: validation either succeeds or raises ValueError with known reasons."""
+def test_validate_sticker_payload_accept_reject_oracle(raw, typ):
+ """Accept only when type, magic, and size agree. Else known ValueError reason."""
+ nt = sticker_utils.normalize_image_type(typ)
+ detected = sticker_utils.detect_image_format_from_magic(raw)
+ expect_ok = (
+ bool(raw)
+ and nt is not None
+ and detected is not None
+ and detected == nt
+ and nt in {"png", "jpeg", "jpg", "gif", "webp", "bmp"}
+ and len(raw) <= sticker_utils.MAX_STICKER_BYTES
+ )
try:
- sticker_utils.validate_sticker_payload(raw, typ)
- except ValueError:
- pass
+ out_type, out_hash = sticker_utils.validate_sticker_payload(raw, typ)
+ except ValueError as exc:
+ assert not expect_ok
+ assert str(exc) in _STICKER_REJECT_REASONS
+ return
+ assert expect_ok
+ assert out_type == detected
+ assert len(out_hash) == 64
+ assert all(c in "0123456789abcdef" for c in out_hash)
@settings(max_examples=500, deadline=None)
@given(raw=st.binary(min_size=0, max_size=4096))
-def test_detect_image_format_from_magic_fuzz_never_raises(raw):
+def test_detect_image_format_from_magic_closed_set(raw):
out = sticker_utils.detect_image_format_from_magic(raw)
assert out is None or out in {"png", "jpeg", "gif", "webp", "bmp", "tgs", "webm"}
+ if len(raw) < 4:
+ assert out is None
+ return
+ if raw.startswith(b"\x89PNG\r\n\x1a\n"):
+ assert out == "png"
+ elif raw.startswith(b"\xff\xd8\xff"):
+ assert out == "jpeg"
+ elif raw.startswith((b"GIF87a", b"GIF89a")):
+ assert out == "gif"
@settings(max_examples=100, deadline=None)
@@ -192,11 +251,22 @@ def test_detect_image_format_from_magic_fuzz_never_raises(raw):
max_size=8,
),
)
-def test_validate_export_document_fuzz_never_raises_unexpected(doc):
+def test_validate_export_document_accept_reject_oracle(doc):
try:
- sticker_utils.validate_export_document(doc)
- except ValueError:
- pass
+ items = sticker_utils.validate_export_document(doc)
+ except ValueError as exc:
+ reason = str(exc)
+ assert any(
+ reason == p or reason.startswith(p) for p in _STICKER_EXPORT_PREFIXES
+ )
+ return
+ assert isinstance(items, list)
+ assert doc.get("format") == "meshchatx-stickers"
+ assert int(doc["version"]) == 1
+ assert isinstance(doc.get("stickers"), list)
+ for item in items:
+ assert isinstance(item["image_bytes_b64"], str)
+ assert item["image_bytes_b64"]
def _build_tgs(
diff --git a/tests/backend/test_web_audio_bridge.py b/tests/backend/test_web_audio_bridge.py
index 08c68462..9ee744d7 100644
--- a/tests/backend/test_web_audio_bridge.py
+++ b/tests/backend/test_web_audio_bridge.py
@@ -261,7 +261,9 @@ async def test_send_status_uses_tele_frame_ms():
client = AsyncMock()
await bridge.send_status(client)
payload = json.loads(client.send_str.await_args.args[0])
- assert payload == {"type": "web_audio.ready", "frame_ms": 52}
+ assert payload["type"] == "web_audio.ready"
+ assert payload["frame_ms"] == 52
+ assert payload["required"] is False
@pytest.mark.asyncio
@@ -283,7 +285,19 @@ async def test_send_status_defaults_when_no_telephone():
client = AsyncMock()
await bridge.send_status(client)
payload = json.loads(client.send_str.await_args.args[0])
- assert payload == {"type": "web_audio.ready", "frame_ms": 60}
+ assert payload["type"] == "web_audio.ready"
+ assert payload["frame_ms"] == 60
+ assert payload["required"] is False
+
+
+@pytest.mark.asyncio
+async def test_send_status_marks_required_when_force_enabled():
+ bridge = WebAudioBridge(_TeleMgrNoTelephone(), MagicMock(), force_enabled=True)
+ client = AsyncMock()
+ await bridge.send_status(client)
+ payload = json.loads(client.send_str.await_args.args[0])
+ assert payload["required"] is True
+ assert bridge.get_diagnostics()["force_enabled"] is True
@patch("meshchatx.src.backend.web_audio_bridge.Pipeline")
diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js
index b9556792..a5923014 100644
--- a/tests/frontend/CallPage.test.js
+++ b/tests/frontend/CallPage.test.js
@@ -757,4 +757,21 @@ describe("CallPage.vue", () => {
expect(wrapper.vm.callMinimized).toBe(false);
});
});
+
+ describe("web audio required (headless / Android)", () => {
+ it("oracle: required status forces webAudioBridgeEnabled and blocks disable", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ wrapper.vm.config = { telephone_enabled: true, telephone_web_audio_enabled: false };
+ await wrapper.vm.ensureWebAudio({
+ enabled: true,
+ required: true,
+ frame_ms: 60,
+ });
+ expect(wrapper.vm.webAudioBridgeRequired).toBe(true);
+ expect(wrapper.vm.webAudioBridgeEnabled).toBe(true);
+ await wrapper.vm.onToggleWebAudio(false);
+ expect(wrapper.vm.webAudioBridgeEnabled).toBe(true);
+ });
+ });
});
diff --git a/tests/frontend/RnsFilesyncPage.test.js b/tests/frontend/RnsFilesyncPage.test.js
index 032f5d59..2e1efd8b 100644
--- a/tests/frontend/RnsFilesyncPage.test.js
+++ b/tests/frontend/RnsFilesyncPage.test.js
@@ -96,7 +96,7 @@ describe("RnsFilesyncPage.vue", () => {
expect.objectContaining({
sync_directory: "/tmp/sync",
monitor: true,
- }),
+ })
);
expect(ToastUtils.success).toHaveBeenCalledWith("rns_filesync.started");
});
diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index b25e6f57..1d6b1260 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -271,6 +271,58 @@ describe("behavior contracts: plugin install permissions", () => {
});
});
+describe("behavior contracts: phased startup and early UI mount", () => {
+ it("frontend mounts on ui_ready and continues polling for mesh ready", () => {
+ const wait = readSource("meshchatx/src/frontend/js/networkStartupWait.js");
+ expect(wait).toContain('kind: "ui"');
+ expect(wait).toContain("mountOnUiReady");
+ expect(wait).toContain("waitForMeshReady");
+ expect(wait).toContain("hasOwnProperty.call");
+ const main = readSource("meshchatx/src/frontend/main.js");
+ expect(main).toContain("waitForMeshReady");
+ expect(main).toContain('networkReady === "ui"');
+ expect(main).toContain("networkStarting");
+ expect(main).toContain('from "./locales/en.json"');
+ expect(main).not.toMatch(/import\.meta\.glob\(\s*["'].*locales.*["']\s*,\s*\{\s*eager:\s*true/);
+ });
+
+ it("App gates shell until mesh ready and shows starting banner", () => {
+ const app = readSource("meshchatx/src/frontend/components/App.vue");
+ expect(app).toContain("waitForMeshThenStartShell");
+ expect(app).toContain("showNetworkStartingBanner");
+ expect(app).toContain("networkStarting");
+ expect(app).toContain("network_starting");
+ const banners = readSource("meshchatx/src/frontend/components/layout/AppShellBanners.vue");
+ expect(banners).toContain("showNetworkStarting");
+ expect(banners).toContain("networkStartingLabel");
+ });
+
+ it("backend publishes ui_ready early and defers secondary identity services", () => {
+ const mesh = readSource("meshchatx/meshchat.py");
+ expect(mesh).toContain("self._ui_ready = True");
+ expect(mesh).toContain("_finish_deferred_startup_services");
+ expect(mesh).toContain("_start_deferred_reticulum_services");
+ const identity = readSource("meshchatx/src/backend/identity_context.py");
+ expect(identity).toContain("def setup_deferred_services");
+ expect(identity).toContain("_deferred_setup_in_progress");
+ expect(identity).toContain("_deferred_setup_finished");
+ expect(identity).toContain("critical_only=True");
+ expect(identity).toContain("quick=True");
+ expect(identity).toContain("populate=False");
+ });
+
+ it("integrity critical path and docs populate helpers stay available", () => {
+ const integrity = readSource("meshchatx/src/backend/integrity_manager.py");
+ expect(integrity).toContain("critical_only");
+ const docs = readSource("meshchatx/src/backend/docs_manager.py");
+ expect(docs).toContain("ensure_meshchatx_docs_populated");
+ expect(docs).toContain("populate: bool = True");
+ const database = readSource("meshchatx/src/backend/database/__init__.py");
+ expect(database).toContain("quick: bool = False");
+ expect(database).toContain("quick_check");
+ });
+});
+
describe("behavior contracts: network visualiser performance", () => {
it("keeps lean physics and edge-hide options for large meshes", () => {
const src = readSource("meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue");
diff --git a/tests/frontend/localeLoader.test.js b/tests/frontend/localeLoader.test.js
new file mode 100644
index 00000000..9833f2b2
--- /dev/null
+++ b/tests/frontend/localeLoader.test.js
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it, vi } from "vitest";
+import { createI18n } from "vue-i18n";
+import { ensureLocaleMessages, listLocaleCodes, setLocale } from "../../meshchatx/src/frontend/js/localeLoader.js";
+
+describe("localeLoader", () => {
+ it("lists locale codes with english first", () => {
+ const codes = listLocaleCodes();
+ expect(codes[0]).toBe("en");
+ expect(codes).toEqual(expect.arrayContaining(["de", "es", "fr", "ru", "zh"]));
+ expect(new Set(codes).size).toBe(codes.length);
+ });
+
+ it("ensureLocaleMessages rejects bad codes without throwing", async () => {
+ const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
+ for (const bad of [null, undefined, "", 12, {}, [], "nope-xx"]) {
+ await expect(ensureLocaleMessages(i18n, bad)).resolves.toBe(false);
+ }
+ });
+
+ it("ensureLocaleMessages is idempotent for already-loaded locales", async () => {
+ const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
+ expect(await ensureLocaleMessages(i18n, "en")).toBe(true);
+ expect(await ensureLocaleMessages(i18n, "en")).toBe(true);
+ });
+
+ it("loads a non-english locale into the composer", async () => {
+ const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
+ expect(i18n.global.availableLocales).not.toContain("de");
+ expect(await ensureLocaleMessages(i18n, "de")).toBe(true);
+ expect(i18n.global.availableLocales).toContain("de");
+ expect(i18n.global.getLocaleMessage("de")._languageName).toBeTruthy();
+ });
+
+ it("setLocale updates locale.value for composition i18n", async () => {
+ const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
+ expect(await setLocale(i18n, "fr")).toBe(true);
+ expect(i18n.global.locale.value).toBe("fr");
+ });
+
+ it("setLocale works when given the composer directly", async () => {
+ const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
+ expect(await setLocale(i18n.global, "es")).toBe(true);
+ expect(i18n.global.locale.value).toBe("es");
+ });
+
+ it("ensureLocaleMessages returns false when setLocaleMessage is missing", async () => {
+ const fake = { availableLocales: [], setLocaleMessage: undefined };
+ expect(await ensureLocaleMessages(fake, "de")).toBe(false);
+ });
+
+ it("adversarial: ensureLocaleMessages(null) is false", async () => {
+ expect(await ensureLocaleMessages(null, "en")).toBe(false);
+ expect(await setLocale(undefined, "en")).toBe(false);
+ });
+
+ it("fuzz: random codes never throw", async () => {
+ const i18n = createI18n({ legacy: false, locale: "en", messages: { en: {} } });
+ const junk = ["", "en", "de", "../en", "EN", "en.json", "🚀", "a".repeat(200), "zh-CN", "pt"];
+ for (const code of junk) {
+ await expect(ensureLocaleMessages(i18n, code)).resolves.toBeTypeOf("boolean");
+ }
+ });
+});
diff --git a/tests/frontend/networkStartupWait.adversarial.test.js b/tests/frontend/networkStartupWait.adversarial.test.js
new file mode 100644
index 00000000..ba37ce7b
--- /dev/null
+++ b/tests/frontend/networkStartupWait.adversarial.test.js
@@ -0,0 +1,289 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Adversarial fuzzing and oracles for early-UI / mesh-ready boot gating.
+ */
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ STARTUP_STAGE_LABELS,
+ interpretStartupStatus,
+ waitForMeshReady,
+ waitForNetworkReady,
+} from "../../meshchatx/src/frontend/js/networkStartupWait.js";
+
+function assertInterpretOracle(result, data) {
+ expect(result).toBeTruthy();
+ expect(typeof result.kind).toBe("string");
+ expect(["ready", "ui", "degraded", "failed", "starting", "invalid"]).toContain(result.kind);
+
+ const own = (key) => Object.prototype.hasOwnProperty.call(data, key);
+ const networkReady = own("network_ready") && data.network_ready === true;
+ const uiReady = own("ui_ready") && data.ui_ready === true;
+ const networkDegraded = own("network_degraded") && data.network_degraded === true;
+ const status = own("status") ? data.status : undefined;
+
+ if (result.kind === "ready") {
+ expect(status === "ok" || networkReady).toBe(true);
+ }
+ if (result.kind === "ui") {
+ expect(uiReady).toBe(true);
+ expect(status === "starting" || status === undefined).toBe(true);
+ expect(networkReady).toBe(false);
+ }
+ if (result.kind === "degraded") {
+ expect(status).toBe("failed");
+ expect(uiReady || networkDegraded).toBe(true);
+ }
+ if (result.kind === "failed") {
+ expect(status).toBe("failed");
+ expect(uiReady).toBe(false);
+ expect(networkDegraded).toBe(false);
+ }
+ if (result.kind === "starting") {
+ expect(uiReady).toBe(false);
+ }
+}
+
+function mulberry32(seed) {
+ let t = seed >>> 0;
+ return () => {
+ t += 0x6d2b79f5;
+ let r = Math.imul(t ^ (t >>> 15), 1 | t);
+ r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+describe("networkStartupWait adversarial / oracle", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("oracle: ready beats starting labels when network_ready is true", () => {
+ const data = { status: "starting", stage: "identity", network_ready: true, ui_ready: true };
+ const result = interpretStartupStatus(data);
+ expect(result.kind).toBe("ready");
+ assertInterpretOracle(result, data);
+ });
+
+ it("oracle: failed is checked before network_ready spoof", () => {
+ const data = {
+ status: "failed",
+ network_ready: true,
+ ui_ready: false,
+ network_degraded: false,
+ error: "spoof",
+ };
+ const result = interpretStartupStatus(data);
+ expect(result.kind).toBe("failed");
+ assertInterpretOracle(result, data);
+ });
+
+ it("oracle: only strict boolean true mounts UI early", () => {
+ for (const fake of [1, "true", "yes", {}, [], "1"]) {
+ const data = { status: "starting", stage: "rns", ui_ready: fake, network_ready: false };
+ const result = interpretStartupStatus(data);
+ expect(result.kind).toBe("starting");
+ assertInterpretOracle(result, data);
+ }
+ const ok = interpretStartupStatus({
+ status: "starting",
+ stage: "rns",
+ ui_ready: true,
+ network_ready: false,
+ });
+ expect(ok.kind).toBe("ui");
+ });
+
+ it("oracle: degraded requires failed + recovery flag", () => {
+ expect(
+ interpretStartupStatus({
+ status: "failed",
+ ui_ready: true,
+ error: "x",
+ }).kind
+ ).toBe("degraded");
+ expect(
+ interpretStartupStatus({
+ status: "failed",
+ network_degraded: true,
+ error: "y",
+ }).kind
+ ).toBe("degraded");
+ expect(interpretStartupStatus({ status: "failed", error: "z" }).kind).toBe("failed");
+ });
+
+ it("fuzz: random status payloads never throw and obey oracle", () => {
+ const rand = mulberry32(0x51a7);
+ const statuses = ["ok", "starting", "failed", undefined, null, "nope", 0, "", "OK"];
+ const stages = [...Object.keys(STARTUP_STAGE_LABELS), undefined, null, "???", 12];
+ for (let i = 0; i < 400; i++) {
+ const data = {
+ status: statuses[Math.floor(rand() * statuses.length)],
+ stage: stages[Math.floor(rand() * stages.length)],
+ network_ready: rand() < 0.3 ? true : rand() < 0.5 ? false : rand() < 0.5 ? 1 : "true",
+ ui_ready: rand() < 0.3 ? true : rand() < 0.5 ? false : rand() < 0.5 ? 1 : "yes",
+ network_degraded: rand() < 0.2,
+ error: rand() < 0.2 ? "err" : rand() < 0.1 ? 123 : undefined,
+ };
+ let result;
+ expect(() => {
+ result = interpretStartupStatus(data);
+ }).not.toThrow();
+ if (data && typeof data === "object" && !Array.isArray(data)) {
+ assertInterpretOracle(result, data);
+ } else {
+ expect(result.kind).toBe("invalid");
+ }
+ }
+ });
+
+ it("fuzz: interpretStartupStatus rejects non-objects without throwing", () => {
+ for (const junk of [null, undefined, 0, 1, "", "starting", true, false, [], () => {}, Symbol("x")]) {
+ expect(() => interpretStartupStatus(junk)).not.toThrow();
+ expect(interpretStartupStatus(junk).kind).toBe("invalid");
+ }
+ });
+
+ it("waitForMeshReady never returns ui even when ui_ready is set", async () => {
+ let calls = 0;
+ const result = await waitForMeshReady({
+ fetchImpl: async () => {
+ calls += 1;
+ if (calls < 3) {
+ return {
+ ok: true,
+ json: async () => ({
+ status: "starting",
+ stage: "identity",
+ ui_ready: true,
+ network_ready: false,
+ }),
+ };
+ }
+ return {
+ ok: true,
+ json: async () => ({ status: "ok", network_ready: true, ui_ready: true }),
+ };
+ },
+ sleep: async () => {},
+ timeoutMs: 5000,
+ });
+ expect(result).toBe("ready");
+ expect(calls).toBeGreaterThanOrEqual(3);
+ });
+
+ it("waitForNetworkReady prefers ready over ui on the same payload", async () => {
+ const result = await waitForNetworkReady({
+ fetchImpl: async () => ({
+ ok: true,
+ json: async () => ({
+ status: "ok",
+ network_ready: true,
+ ui_ready: true,
+ stage: "ready",
+ }),
+ }),
+ sleep: async () => {},
+ timeoutMs: 1000,
+ });
+ expect(result).toBe("ready");
+ });
+
+ it("waitForNetworkReady ignores non-ok HTTP and keeps polling", async () => {
+ let calls = 0;
+ const result = await waitForNetworkReady({
+ fetchImpl: async () => {
+ calls += 1;
+ if (calls === 1) {
+ return { ok: false, status: 503, json: async () => ({}) };
+ }
+ if (calls === 2) {
+ return {
+ ok: true,
+ json: async () => ({
+ status: "starting",
+ ui_ready: true,
+ stage: "http",
+ }),
+ };
+ }
+ return { ok: true, json: async () => ({ status: "ok", network_ready: true }) };
+ },
+ sleep: async () => {},
+ timeoutMs: 5000,
+ mountOnUiReady: true,
+ });
+ expect(result).toBe("ui");
+ });
+
+ it("waitForNetworkReady: malformed JSON is treated as still starting", async () => {
+ let calls = 0;
+ const lines = [];
+ const result = await waitForNetworkReady({
+ fetchImpl: async () => {
+ calls += 1;
+ if (calls < 2) {
+ return {
+ ok: true,
+ json: async () => {
+ throw new Error("bad json");
+ },
+ };
+ }
+ return {
+ ok: true,
+ json: async () => ({ status: "ok", network_ready: true }),
+ };
+ },
+ sleep: async () => {},
+ timeoutMs: 5000,
+ onLine: (text) => lines.push(text),
+ mountOnUiReady: false,
+ });
+ expect(result).toBe("ready");
+ expect(lines).toContain("Still starting…");
+ });
+
+ it("adversarial: prototype pollution cannot spoof network_ready or ui_ready", () => {
+ const polluted = { status: "starting", stage: "rns" };
+ Object.setPrototypeOf(polluted, {
+ network_ready: true,
+ ui_ready: true,
+ network_degraded: true,
+ });
+ const result = interpretStartupStatus(polluted);
+ expect(result.kind).toBe("starting");
+ assertInterpretOracle(result, polluted);
+
+ const ownUi = { status: "starting", stage: "rns", ui_ready: true };
+ Object.setPrototypeOf(ownUi, { network_ready: true });
+ expect(interpretStartupStatus(ownUi).kind).toBe("ui");
+ });
+
+ it("adversarial: arrays are invalid even with status-like indexes", () => {
+ const arr = ["starting"];
+ arr.status = "starting";
+ arr.ui_ready = true;
+ expect(interpretStartupStatus(arr).kind).toBe("invalid");
+ });
+
+ it("priority matrix: status/network_ready/ui_ready combinations", () => {
+ const matrix = [
+ [{ status: "ok", network_ready: false, ui_ready: false }, "ready"],
+ [{ status: "starting", network_ready: true, ui_ready: false }, "ready"],
+ [{ status: "starting", network_ready: false, ui_ready: true }, "ui"],
+ [{ status: "starting", network_ready: false, ui_ready: false }, "starting"],
+ [{ status: "failed", ui_ready: true }, "degraded"],
+ [{ status: "failed", network_degraded: true }, "degraded"],
+ [{ status: "failed" }, "failed"],
+ [{ status: "wat" }, "invalid"],
+ ];
+ for (const [data, kind] of matrix) {
+ const result = interpretStartupStatus(data);
+ expect(result.kind).toBe(kind);
+ assertInterpretOracle(result, data);
+ }
+ });
+});
diff --git a/tests/frontend/networkStartupWait.test.js b/tests/frontend/networkStartupWait.test.js
index b56e3430..cded3076 100644
--- a/tests/frontend/networkStartupWait.test.js
+++ b/tests/frontend/networkStartupWait.test.js
@@ -48,12 +48,27 @@ describe("networkStartupWait", () => {
});
});
+ it("interpretStartupStatus marks ui when ui_ready during starting", () => {
+ expect(
+ interpretStartupStatus({
+ status: "starting",
+ stage: "rns",
+ network_ready: false,
+ ui_ready: true,
+ })
+ ).toEqual({
+ kind: "ui",
+ stage: "rns",
+ label: STARTUP_STAGE_LABELS.rns,
+ });
+ });
+
it("interpretStartupStatus maps starting stages to labels", () => {
for (const stage of Object.keys(STARTUP_STAGE_LABELS)) {
if (stage === "ready" || stage === "failed") {
continue;
}
- const result = interpretStartupStatus({ status: "starting", stage });
+ const result = interpretStartupStatus({ status: "starting", stage, ui_ready: false });
expect(result.kind).toBe("starting");
expect(result.label).toBe(STARTUP_STAGE_LABELS[stage]);
}
@@ -73,7 +88,12 @@ describe("networkStartupWait", () => {
if (calls < 3) {
return {
ok: true,
- json: async () => ({ status: "starting", stage: "rns", network_ready: false }),
+ json: async () => ({
+ status: "starting",
+ stage: "rns",
+ network_ready: false,
+ ui_ready: false,
+ }),
};
}
return {
@@ -86,12 +106,33 @@ describe("networkStartupWait", () => {
sleep: async () => {},
timeoutMs: 5000,
onLine: (text) => lines.push(text),
+ mountOnUiReady: false,
});
expect(ready).toBe("ready");
expect(lines).toContain(STARTUP_STAGE_LABELS.rns);
expect(fetchImpl).toHaveBeenCalled();
});
+ it("waitForNetworkReady mounts early when ui_ready", async () => {
+ const lines = [];
+ const ready = await waitForNetworkReady({
+ fetchImpl: async () => ({
+ ok: true,
+ json: async () => ({
+ status: "starting",
+ stage: "identity",
+ network_ready: false,
+ ui_ready: true,
+ }),
+ }),
+ sleep: async () => {},
+ timeoutMs: 1000,
+ onLine: (text) => lines.push(text),
+ });
+ expect(ready).toBe("ui");
+ expect(lines).toContain(STARTUP_STAGE_LABELS.identity);
+ });
+
it("waitForNetworkReady returns false on failed status", async () => {
const errors = [];
const ready = await waitForNetworkReady({
@@ -158,7 +199,12 @@ describe("networkStartupWait", () => {
const ready = await waitForNetworkReady({
fetchImpl: async () => ({
ok: true,
- json: async () => ({ status: "starting", stage: "identity", network_ready: false }),
+ json: async () => ({
+ status: "starting",
+ stage: "identity",
+ network_ready: false,
+ ui_ready: false,
+ }),
}),
now: () => now,
sleep: async () => {
@@ -167,6 +213,7 @@ describe("networkStartupWait", () => {
timeoutMs: 1000,
onLine: (text) => lines.push(text),
onErrorState: () => errors.push("error"),
+ mountOnUiReady: false,
});
expect(ready).toBe(false);
expect(errors).toEqual(["error"]);
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────